From c6b4ae71d8c1a2834773d95e18d279124fcf1e30 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sun, 31 May 2026 21:40:42 +0800 Subject: [PATCH 01/11] feat(yjs): add collaboration package --- .changeset/slate-yjs-from-scratch.md | 5 + bun.lock | 28 + .../2026-05-25-yjs-demo-four-editors-marks.md | 40 + ...05-25-yjs-offline-mixed-edits-reconnect.md | 52 + .../2026-05-26-yjs-history-split-fixes.md | 45 + .../2026-05-26-yjs-offline-mark-stale-undo.md | 50 + .../2026-05-26-yjs-offline-merge-undo-noop.md | 34 + .../2026-05-26-yjs-offline-move-undo-redo.md | 38 + ...-05-26-yjs-potion-differential-bug-hunt.md | 56 + ...026-05-26-yjs-potion-differential-fixes.md | 56 + .../2026-05-27-cmd-a-delete-empty-root.md | 89 + .../2026-05-27-yjs-collaboration-soak.md | 50 + ...5-27-yjs-merge-node-canonical-late-peer.md | 57 + .../2026-05-28-yjs-command-matrix-controls.md | 22 + ...026-05-28-yjs-potion-parity-regressions.md | 54 + docs/plans/2026-05-28-yjs-public-split-api.md | 24 + .../yjs-awareness-react-hooks-2026-05-29.md | 100 ++ ...erge-normalization-reconnect-2026-05-25.md | 113 ++ ...orward-move-history-fallback-2026-05-26.md | 63 + ...rge-read-virtual-text-leaves-2026-05-27.md | 112 ++ ...yjs-offline-merge-stale-undo-2026-05-26.md | 91 + ...ffline-split-reconnect-merge-2026-05-25.md | 93 + ...history-empty-leaf-reconnect-2026-05-26.md | 74 + ...uctural-wrap-fragment-parity-2026-05-28.md | 89 + ...ext-leaf-metadata-delta-sync-2026-05-26.md | 129 ++ ...ate-empty-root-normalization-2026-05-27.md | 123 ++ ...onnected-undo-history-offset-2026-05-25.md | 163 ++ ...xample-client-id-determinism-2026-05-28.md | 64 + ...t-respect-other-editor-roots-2026-05-25.md | 81 + ...act-structural-text-dom-sync-2026-05-28.md | 92 + package.json | 3 +- .../test/kernel-authority-audit-contract.ts | 4 +- .../slate-react/test/surface-contract.tsx | 14 +- packages/slate-yjs/package.json | 63 + packages/slate-yjs/src/core/awareness.ts | 76 + packages/slate-yjs/src/core/controller.ts | 625 +++++++ packages/slate-yjs/src/core/document.ts | 507 ++++++ packages/slate-yjs/src/core/extension.ts | 29 + packages/slate-yjs/src/core/index.ts | 4 + packages/slate-yjs/src/core/operations.ts | 529 ++++++ packages/slate-yjs/src/core/selection.ts | 79 + packages/slate-yjs/src/core/types.ts | 100 ++ .../src/core/undo-manager-adapter.ts | 65 + packages/slate-yjs/src/index.ts | 1 + packages/slate-yjs/src/internal/index.ts | 1 + packages/slate-yjs/src/react/index.ts | 41 + .../slate-yjs/test/awareness-contract.spec.ts | 165 ++ .../test/delete-fragment-contract.spec.ts | 199 +++ .../test/insert-fragment-contract.spec.ts | 179 ++ .../test/lift-nodes-contract.spec.ts | 504 ++++++ .../test/merge-node-contract.spec.ts | 281 +++ .../slate-yjs/test/move-node-contract.spec.ts | 276 +++ .../test/package-config-contract.spec.ts | 27 + .../test/remove-node-contract.spec.ts | 187 ++ .../test/replace-fragment-contract.spec.ts | 303 ++++ .../slate-yjs/test/selection-contract.spec.ts | 148 ++ .../slate-yjs/test/set-node-contract.spec.ts | 284 +++ .../test/simple-operations-contract.spec.ts | 253 +++ .../test/split-node-contract.spec.ts | 240 +++ .../slate-yjs/test/support/collaboration.ts | 228 +++ .../undo-manager-adapter-contract.spec.ts | 62 + .../test/unwrap-nodes-contract.spec.ts | 211 +++ .../test/wrap-nodes-contract.spec.ts | 202 +++ packages/slate-yjs/tsconfig.build.json | 12 + packages/slate-yjs/tsconfig.json | 7 + packages/slate-yjs/tsdown.config.mts | 25 + packages/slate/src/core/public-state.ts | 45 +- packages/slate/test/snapshot-contract.ts | 5 + .../examples/synced-blocks.test.ts | 6 +- .../examples/yjs-collaboration.test.ts | 1518 +++++++++++++++++ site/constants/examples.ts | 1 + site/examples/ts/pagination.tsx | 23 +- site/examples/ts/yjs-collaboration.tsx | 1373 +++++++++++++++ site/pages/examples/[example].tsx | 1 + site/tsconfig.json | 3 + 75 files changed, 10966 insertions(+), 30 deletions(-) create mode 100644 .changeset/slate-yjs-from-scratch.md create mode 100644 docs/plans/2026-05-25-yjs-demo-four-editors-marks.md create mode 100644 docs/plans/2026-05-25-yjs-offline-mixed-edits-reconnect.md create mode 100644 docs/plans/2026-05-26-yjs-history-split-fixes.md create mode 100644 docs/plans/2026-05-26-yjs-offline-mark-stale-undo.md create mode 100644 docs/plans/2026-05-26-yjs-offline-merge-undo-noop.md create mode 100644 docs/plans/2026-05-26-yjs-offline-move-undo-redo.md create mode 100644 docs/plans/2026-05-26-yjs-potion-differential-bug-hunt.md create mode 100644 docs/plans/2026-05-26-yjs-potion-differential-fixes.md create mode 100644 docs/plans/2026-05-27-cmd-a-delete-empty-root.md create mode 100644 docs/plans/2026-05-27-yjs-collaboration-soak.md create mode 100644 docs/plans/2026-05-27-yjs-merge-node-canonical-late-peer.md create mode 100644 docs/plans/2026-05-28-yjs-command-matrix-controls.md create mode 100644 docs/plans/2026-05-28-yjs-potion-parity-regressions.md create mode 100644 docs/plans/2026-05-28-yjs-public-split-api.md create mode 100644 docs/solutions/developer-experience/yjs-awareness-react-hooks-2026-05-29.md create mode 100644 docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md create mode 100644 docs/solutions/logic-errors/yjs-forward-move-history-fallback-2026-05-26.md create mode 100644 docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md create mode 100644 docs/solutions/logic-errors/yjs-offline-merge-stale-undo-2026-05-26.md create mode 100644 docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md create mode 100644 docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md create mode 100644 docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md create mode 100644 docs/solutions/logic-errors/yjs-text-leaf-metadata-delta-sync-2026-05-26.md create mode 100644 docs/solutions/runtime-errors/slate-empty-root-normalization-2026-05-27.md create mode 100644 docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md create mode 100644 docs/solutions/test-failures/yjs-example-client-id-determinism-2026-05-28.md create mode 100644 docs/solutions/ui-bugs/slate-react-selection-export-must-respect-other-editor-roots-2026-05-25.md create mode 100644 docs/solutions/ui-bugs/slate-react-structural-text-dom-sync-2026-05-28.md create mode 100644 packages/slate-yjs/package.json create mode 100644 packages/slate-yjs/src/core/awareness.ts create mode 100644 packages/slate-yjs/src/core/controller.ts create mode 100644 packages/slate-yjs/src/core/document.ts create mode 100644 packages/slate-yjs/src/core/extension.ts create mode 100644 packages/slate-yjs/src/core/index.ts create mode 100644 packages/slate-yjs/src/core/operations.ts create mode 100644 packages/slate-yjs/src/core/selection.ts create mode 100644 packages/slate-yjs/src/core/types.ts create mode 100644 packages/slate-yjs/src/core/undo-manager-adapter.ts create mode 100644 packages/slate-yjs/src/index.ts create mode 100644 packages/slate-yjs/src/internal/index.ts create mode 100644 packages/slate-yjs/src/react/index.ts create mode 100644 packages/slate-yjs/test/awareness-contract.spec.ts create mode 100644 packages/slate-yjs/test/delete-fragment-contract.spec.ts create mode 100644 packages/slate-yjs/test/insert-fragment-contract.spec.ts create mode 100644 packages/slate-yjs/test/lift-nodes-contract.spec.ts create mode 100644 packages/slate-yjs/test/merge-node-contract.spec.ts create mode 100644 packages/slate-yjs/test/move-node-contract.spec.ts create mode 100644 packages/slate-yjs/test/package-config-contract.spec.ts create mode 100644 packages/slate-yjs/test/remove-node-contract.spec.ts create mode 100644 packages/slate-yjs/test/replace-fragment-contract.spec.ts create mode 100644 packages/slate-yjs/test/selection-contract.spec.ts create mode 100644 packages/slate-yjs/test/set-node-contract.spec.ts create mode 100644 packages/slate-yjs/test/simple-operations-contract.spec.ts create mode 100644 packages/slate-yjs/test/split-node-contract.spec.ts create mode 100644 packages/slate-yjs/test/support/collaboration.ts create mode 100644 packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts create mode 100644 packages/slate-yjs/test/unwrap-nodes-contract.spec.ts create mode 100644 packages/slate-yjs/test/wrap-nodes-contract.spec.ts create mode 100644 packages/slate-yjs/tsconfig.build.json create mode 100644 packages/slate-yjs/tsconfig.json create mode 100644 packages/slate-yjs/tsdown.config.mts create mode 100644 playwright/integration/examples/yjs-collaboration.test.ts create mode 100644 site/examples/ts/yjs-collaboration.tsx diff --git a/.changeset/slate-yjs-from-scratch.md b/.changeset/slate-yjs-from-scratch.md new file mode 100644 index 0000000000..823f078211 --- /dev/null +++ b/.changeset/slate-yjs-from-scratch.md @@ -0,0 +1,5 @@ +--- +"@slate/yjs": patch +--- + +Add the first-party Yjs collaboration package scaffold with operation-level text, node, remove, split, merge, move, set, replace-children, replace-fragment, insert-fragment, delete-fragment, wrap, unwrap, and lift collaboration support, plus selection relative-position conversion, split undo repair, and virtual merge/wrapper refs for concurrent remote edits. Pin the audited Yjs UndoManager stack contract and keep broad replace-fragment fallback traceable as an identity-risk path. diff --git a/bun.lock b/bun.lock index 82a9226877..b000413485 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "tw-animate-css": "^1.4.0", "typescript": "6.0.3", "ultracite": "7.4.4", + "yjs": "13.6.30", }, }, "packages/slate": { @@ -170,6 +171,25 @@ "slate-dom": ">=0.124.2", }, }, + "packages/slate-yjs": { + "name": "@slate/yjs", + "version": "0.0.0", + "dependencies": { + "yjs": "13.6.30", + }, + "devDependencies": { + "@types/node": "^20.8.7", + "@types/react": "^19.2.14", + "react": "^19.2.5", + "slate": "workspace:*", + "slate-history": "workspace:*", + }, + "peerDependencies": { + "react": ">=19.2.0", + "slate": ">=0.124.2", + "yjs": "13.6.30", + }, + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -736,6 +756,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@slate/yjs": ["@slate/yjs@workspace:packages/slate-yjs"], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -1314,6 +1336,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -1350,6 +1374,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1860,6 +1886,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], diff --git a/docs/plans/2026-05-25-yjs-demo-four-editors-marks.md b/docs/plans/2026-05-25-yjs-demo-four-editors-marks.md new file mode 100644 index 0000000000..40f24ac535 --- /dev/null +++ b/docs/plans/2026-05-25-yjs-demo-four-editors-marks.md @@ -0,0 +1,40 @@ +# Yjs Demo Four Editors And Marks + +Date: 2026-05-25 +Status: complete + +## Target + +- Render the `yjs-collaboration` example as a four-editor collaboration demo. +- Match the referenced feel: compact two-column editor cards, editor title, online/offline button, remove button, inline mark toolbar, white editing surface. +- Show offline editors with a red/pink card background. +- Add mark buttons for bold, italic, underline, code, heading one, heading two, quote, ordered list, unordered list, and link-like control. +- Preserve existing collaboration controls needed by tests: append, select, replace, undo, redo, reconcile. +- Keep browser coverage for collaboration behavior and add coverage for the new UI shape and mark sync. + +## Progress + +- Loaded relevant skills and existing Yjs solution notes. +- Inspected the current two-peer `yjs-collaboration` example and Playwright coverage. +- Reworked the example into four data-driven peers. +- Added inline mark/block toolbar controls and red offline card styling. +- Added Playwright coverage for four editors, offline red panels, and mark sync. +- Kept existing collaboration simulation controls testable. +- Restored visible bottom diagnostics for user/client, net/yjs state, counts, and cursors. + +## Verification + +- Passed: `bun lint:fix`. +- Passed: `bun check`. +- Passed: `PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium` (15 tests). +- Passed: `dev-browser --connect http://127.0.0.1:9222` visual check. Screenshot: `/Users/felixfeng/.dev-browser/tmp/yjs-four-editors-users.png`. + +## Follow-Up: Multi-Editor Focus Scope + +- Reproduced: after B/C/D go offline, clicking C moved focus to A and clicking D moved focus to B. +- Root cause: Slate React model-to-DOM selection export could write a stale selection into the document while the browser selection belonged to another `[data-slate-editor]`. +- Fixed: both `syncEditableDOMSelectionToEditor` and `useEditableSelectionReconciler` skip selection export when the current DOM selection is inside another Slate editor root. +- Added Playwright coverage: `keeps offline editor focus scoped to the clicked editor`. +- Passed: `bun check`. +- Passed: `PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium` (16 tests). +- Passed: `dev-browser --connect http://127.0.0.1:9222` repro path; A/B/C/D active selection stayed in the clicked surface. diff --git a/docs/plans/2026-05-25-yjs-offline-mixed-edits-reconnect.md b/docs/plans/2026-05-25-yjs-offline-mixed-edits-reconnect.md new file mode 100644 index 0000000000..3107786fab --- /dev/null +++ b/docs/plans/2026-05-25-yjs-offline-mixed-edits-reconnect.md @@ -0,0 +1,52 @@ +# Yjs Offline Mixed Edits Reconnect + +## Goal + +Fix the four-peer Yjs demo path where disconnected peers make independent mark, +text replacement, and paragraph insertion edits, then reconnect in sequence. +The merged document should preserve the text replacement and insertion instead +of dropping `Hi` or duplicating `Hello world!`. + +## Scope + +- Add Playwright coverage for the browser-visible regression. +- Fix the collaboration/Yjs import or encoding path that causes destructive + snapshot-style reconnect behavior. +- Verify with the focused Yjs example test and package checks. + +## Progress + +- Reproduced manually in `dev-browser`. +- Reviewed existing Yjs solution notes for reconnect history and hidden + replacement containers. +- Added a failing Playwright regression for offline mark, text replacement, and + paragraph insertion edits. +- Fixed `split_node` Yjs encoding so Enter does not fall back to a full-document + snapshot write. +- Verified package build/typecheck, focused core tests, full Yjs example + Playwright, lint, and dev-browser manual repro. +- Codex review found Yjs text leaf metadata/delta drift cases after reconnect. +- Added regressions for concurrent marks, mark removal, same-text split/merge + formatting, collapsed relative selections, and legacy metadata-only documents. +- Updated text reads/writes so delta attributes preserve concurrent edits while + metadata boundaries still load older same-text split/merge documents. +- Threaded version-aware text read options through internal split/merge/move + paths so versionless legacy metadata-only documents keep marks after edits. +- Backfilled Y.Text delta attributes when converting versionless legacy roots + and made versioned exact-boundary delta attr changes win over stale metadata. +- Restored `site/next-env.d.ts` to stable refs-only content after Playwright + rewrote a `.next` import, then verified `bun typecheck:site` with `site/.next` + temporarily absent. +- Fixed the final Codex review formatting finding with `bun lint:fix`. +- Renamed the Slate Yjs core regression file to `core-contract.test.ts` so + `bun --filter @slate/yjs test` discovers the coverage. +- Added legacy metadata regressions for full-range mark additions and partial + same-key delta formatting; package test discovery now runs 43 Yjs core tests. +- Added null text-attribute round-trip coverage and kept null metadata fallback + for delta-preferred reads, since Yjs deltas omit null-valued attributes. +- Added versioned metadata-only split mark-removal coverage so old same-text + split/merge documents do not reload removed marks from uniform Yjs deltas. +- Narrowed metadata control to cases where present metadata values agree with + the uniform delta value, so stale metadata cannot override real delta changes. +- Captured the metadata/delta reconciliation learning in + `docs/solutions/logic-errors/yjs-text-leaf-metadata-delta-sync-2026-05-26.md`. diff --git a/docs/plans/2026-05-26-yjs-history-split-fixes.md b/docs/plans/2026-05-26-yjs-history-split-fixes.md new file mode 100644 index 0000000000..3f89fb4ef6 --- /dev/null +++ b/docs/plans/2026-05-26-yjs-history-split-fixes.md @@ -0,0 +1,45 @@ +# Yjs history and split fix plan + +## Goal + +Fix: + +1. Offline `split_node` plus online concurrent insert rebases insert to document start. +2. Offline split-at-end plus typed paragraph leaves stale empty paragraph after one undo. +3. Rapid example Undo/Redo buttons leave remote editor DOM heights inconsistent and FEFF artifacts visible in text. + +## Constraints + +- TDD first: add failing behavior tests before code changes. +- Prefer package/core tests for collaboration semantics and Playwright only for DOM/example regressions. +- Touched published package likely needs a changeset. + +## Phases + +1. Add red tests. Done. +2. Fix operation/Yjs encoding or history bridging at the owning seam. Done. +3. Fix example/history button DOM repair path if needed. Done. +4. Run targeted package tests, targeted Playwright, typecheck, lint. Done. +5. Add changeset and final browser proof. Done. + +## Findings + +- Prior solution says button history must route through user history, but current button path misses keyboard DOM repair behavior. +- Prior solution says remote import/history repair must run before React history availability subscribers read stale state. +- Current repro shows local model convergence can be right while remote DOM keeps FEFF/line-break artifacts. +- `splitYjsTextAtLeafIndex` must delete only the tail from the original `Y.XmlText`; full delete/reinsert breaks Yjs item identity and repositions concurrent inserts. +- `slate-history` needs to merge word typing that starts in the paragraph created by the immediately previous `insertBreak` batch; otherwise one undo leaves the split-created paragraph behind. +- Cross-`Y.XmlText` merge cleanup must delete empty merged leaves; otherwise remote peers accumulate empty leaves that inflate paragraph height after redo. + +## Verification + +- `bun test ./packages/slate-yjs/test` +- `bun test ./packages/slate-history/test --path-ignore-patterns ""` +- `PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium --grep "offline split|rapid history button replay|offline Backspace merge|offline move|offline block removal" --reporter=list` +- `bun --filter slate-history typecheck` +- `bun --filter @slate/yjs typecheck` +- `bun typecheck:site` +- `bun typecheck:root` +- `bun lint:fix` +- `dev-browser --connect http://127.0.0.1:9222` manual replay of the rapid button scenario: all peers height 117, paragraph heights 22/22/22/22. +- Captured learning in `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md`. diff --git a/docs/plans/2026-05-26-yjs-offline-mark-stale-undo.md b/docs/plans/2026-05-26-yjs-offline-mark-stale-undo.md new file mode 100644 index 0000000000..f04e1f3978 --- /dev/null +++ b/docs/plans/2026-05-26-yjs-offline-mark-stale-undo.md @@ -0,0 +1,50 @@ +# Yjs Offline Mark Stale Undo + +## Goal + +Fix the stale Slate history batch left after an offline mark edit is deleted by a remote replace, using TDD. + +## Scope + +- Add a Playwright regression for: B disconnects, marks text bold, A replaces document, B reconnects, B Undo is disabled and keyboard Undo has no page error. +- Repair the remote-import history cleanup in `@slate/yjs`. +- Verify with the focused e2e case, relevant package checks, and dev-browser repro. + +## Notes + +- Existing text-offset repair only handles text operations and stale merge positions. +- This bug involves non-text history operations from partial mark application. +- Package code changes likely need a changeset. + +## Progress + +- [x] Loaded task, TDD, testing, planning, learnings, and dev-browser instructions. +- [x] Read prior Slate Yjs history solution docs. +- [x] Add failing regression. +- [x] Fix history repair. +- [x] Verify. + +## Green Test + +The new regression passes after pruning stale `set_node` history batches whose replay preconditions no longer match the converged document. + +Related focused e2e grep also passes: + +- stale local undo after remote replace +- offline mark replace/merge coverage +- offline split reconnect undo +- offline Backspace merge reconnect undo + +## Verification + +- `PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium` +- `bun --filter @slate/yjs test` +- `bun --filter @slate/yjs typecheck` +- `bun lint:fix` +- `dev-browser --connect http://127.0.0.1:9222` exact repro: B offline mark, A replace, B reconnect, Undo disabled, no page errors. + +## Red Test + +`PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium -g "clears stale local undo after a remote replace deletes an offline mark"` + +Failure: after reconnect, `yjs-peer-b-undo` stays enabled. diff --git a/docs/plans/2026-05-26-yjs-offline-merge-undo-noop.md b/docs/plans/2026-05-26-yjs-offline-merge-undo-noop.md new file mode 100644 index 0000000000..a1d6e587a5 --- /dev/null +++ b/docs/plans/2026-05-26-yjs-offline-merge-undo-noop.md @@ -0,0 +1,34 @@ +# Yjs offline merge undo no-op + +## Target + +Match Potion reference for this case: + +1. A writes `alpha` / `beta`. +2. B disconnects. +3. B presses Backspace at the start of `beta`, locally merging to `alphabeta`. +4. A inserts `!` after `alpha`. +5. B reconnects; all peers converge to `alpha!beta`. +6. B undo should not split at the wrong point. Until semantic undo is proven safe, it should no-op and keep `alpha!beta`. + +## Plan + +1. Add failing Playwright coverage. +2. Fix historic Yjs undo fallback so mismatched structural history does not export the wrong Slate replay. +3. Verify with focused Playwright, package tests, typecheck, lint, and dev-browser if needed. + +## Notes + +- Potion reference result: reconnect `alpha!beta`; B undo no-op, still `alpha!beta`. +- Current local result: B undo converges every peer to `alpha` / `!beta`. +- Prior solution docs show stale reconnect history must be repaired or discarded before unsafe replay. + +## Verification + +- Red: focused Playwright failed with `["alpha", "!beta"]`. +- Green: focused Playwright passed. +- Regression: full `yjs-collaboration.test.ts` passed. +- Package: `bun --filter @slate/yjs test` passed. +- Typecheck: `bun typecheck:root` and `bun typecheck:site` passed. +- Lint: `bun lint:fix` passed with no fixes. +- Browser: `dev-browser --connect http://127.0.0.1:9222` reproduced the user steps and kept all peers at `alpha!beta` after `Meta+Z`. diff --git a/docs/plans/2026-05-26-yjs-offline-move-undo-redo.md b/docs/plans/2026-05-26-yjs-offline-move-undo-redo.md new file mode 100644 index 0000000000..f662f91b28 --- /dev/null +++ b/docs/plans/2026-05-26-yjs-offline-move-undo-redo.md @@ -0,0 +1,38 @@ +# Yjs Offline Move Undo/Redo + +## Goal + +Match Potion for this scenario: + +1. B goes offline. +2. B moves `beta` before `alpha`. +3. A appends `!` to `gamma`. +4. B reconnects. +5. B undo/redo converges on every peer. + +## Current Reference + +Potion converges: + +- reconnect: `beta / alpha / gamma!` +- B undo: `alpha / beta / gamma!` +- B redo: `beta / alpha / gamma!` + +## Plan + +1. Add a failing Playwright row in `playwright/integration/examples/yjs-collaboration.test.ts`. Done. +2. Confirm the red reproduces local split behavior. Done. +3. Fix `packages/slate-yjs` structural move history so undo/redo hides/reveals the right Yjs nodes. Done. +4. Run focused Playwright, package typecheck/test, and lint. Done. + +## Notes + +- Existing fixes already cover offline replace, split, text metadata, and stale text history offsets. +- This task changes published `packages/slate-yjs`; keep/adjust changeset coverage before closeout. +- Root cause: fallback `move_node` encoding inserted same-parent forward moves at the pre-hide destination index. With clone-and-hide moves, `[0] -> [1]` must insert one visible slot later before hiding the original node. +- Captured reusable learning in `docs/solutions/logic-errors/yjs-forward-move-history-fallback-2026-05-26.md`. +- Verification: + - `PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium -g "keeps offline move undo and redo converged after reconnect"`: pass + - `bun --filter @slate/yjs test`: pass + - `bun --filter @slate/yjs typecheck`: pass + - `bun run lint:fix`: pass diff --git a/docs/plans/2026-05-26-yjs-potion-differential-bug-hunt.md b/docs/plans/2026-05-26-yjs-potion-differential-bug-hunt.md new file mode 100644 index 0000000000..729f38341c --- /dev/null +++ b/docs/plans/2026-05-26-yjs-potion-differential-bug-hunt.md @@ -0,0 +1,56 @@ +# Yjs Potion differential bug hunt + +## Goal + +Find Slate Yjs collaboration bugs by comparing local `yjs-collaboration` behavior against Potion. Do not fix code in this pass. + +## Scope + +- Local target: `http://localhost:3100/examples/yjs-collaboration` +- Reference target: Potion shared document in persistent debug Chrome +- Method: run local suspicious cases first, then verify mismatches in Potion using per-page CDP offline for B only. + +## High-risk matrix + +1. Offline text merge + remote text edit + reconnect + undo. +2. Offline split + remote text edit + reconnect + undo. +3. Offline remove block + remote text edit + reconnect + undo/redo. +4. Offline move block + remote text edit + reconnect + undo/redo. +5. Offline mark/format + remote text edit + reconnect + undo. +6. Offline replace selection + remote text edit + reconnect + undo. +7. Awareness/presence cleanup across disconnect/reconnect. + +## Running findings + +- Local harness: temporary Playwright sweep against `http://localhost:3100/examples/yjs-collaboration`. +- Potion harness: `dev-browser --connect http://127.0.0.1:9222`, two tabs on `https://potion.platejs.org/SwJVGfk1f913PJc7`, B-only CDP offline. + +### Confirmed bugs by Potion mismatch + +1. `split_node` concurrent remote insert is rebased to the start of the document locally. + - Setup: `alphabeta`. + - B offline: caret at offset 5, `Enter` -> `alpha` / `beta`. + - A online inserts `!` at offset 2, 5, or 7. + - Local after reconnect: always `!alpha` / `beta`. + - Potion after reconnect: + - offset 2: `al!pha` / `beta` + - offset 5: `alpha!` / `beta` + - offset 7: `alpha!` / `beta` + - Local undo preserves the misplaced insert as `!alphabeta`; Potion undo restores the insert at its original logical position. +2. Offline split-at-end + type has different undo grouping from Potion. + - Setup: `alpha`. + - B offline: caret at end, `Enter`, type `beta`. + - A online inserts `!` at end of `alpha`. + - Reconnect converges in both: `alpha!` / `beta`. + - B undo locally: `alpha!` plus an empty paragraph. + - B undo in Potion: single paragraph `alpha!`. + +### Same as Potion / not currently a bug by this oracle + +- Offline remove-node equivalent via deleting the second paragraph, while A edits the deleted paragraph: both Potion and local lose the concurrent `!`; undo restores `beta` without `!`. +- Offline user replace while A inserts text: both Potion and local converge to the replacing snapshot; undo restores `alpha`. +- Offline bold first word while A inserts at the bold boundary: both Potion and local make `!` bold after reconnect; undo removes bold and keeps `!`. + +### Suspicious but not Potion-verified + +- Local offline move of the second block to top while A edits the moved block drops A's `!` after reconnect. Potion drag automation did not trigger a block move, so this is not promoted to confirmed bug yet. diff --git a/docs/plans/2026-05-26-yjs-potion-differential-fixes.md b/docs/plans/2026-05-26-yjs-potion-differential-fixes.md new file mode 100644 index 0000000000..d9b7008a94 --- /dev/null +++ b/docs/plans/2026-05-26-yjs-potion-differential-fixes.md @@ -0,0 +1,56 @@ +# Yjs Potion differential fixes + +## Goal + +Fix Potion-differential Slate Yjs collaboration bugs with TDD. Work in vertical +slices: one failing e2e, one fix, then expand. + +## Confirmed Bugs + +1. Split-at-end + type + remote insert: one undo leaves `alpha! / b` locally, + while Potion restores a single `alpha!` paragraph. +2. Merge + remote insert: local reconnect gives `alpha!beta` and undo no-ops, + while Potion can undo back to `alpha! / beta`. +3. Remove second-block text + remote edit: local drops the remote `!`, while + Potion preserves it as `alpha / !` and undo restores `alpha / beta!`. +4. Replace selected first word + remote append: local undo leaves `A beta!`, + while Potion restores `alpha beta!`. + +## Slices + +1. [x] Add failing Playwright coverage for the four confirmed browser + behaviors. +2. [x] Fix Yjs structural merge/remove/replace import behavior and Slate + history repair at the shared controller boundary. +3. [x] Run focused browser test, package test, package typecheck, lint. + +## Outcome + +- Offline split-at-end + type undo restores the single `alpha!` paragraph. +- Offline Backspace merge + concurrent text insert reconnects to `alpha!beta` + and one undo restores `alpha! / beta`. +- Offline text removal preserves concurrent remote text inside the removed block + and one undo restores `alpha / beta!`. +- Offline selected word replacement preserves concurrent remote text and one + undo restores `alpha beta!`. +- Continued typing after selected text replacement is a single Slate history + undo unit. + +## Verification + +- `bun test ./packages/slate-history/test/history-contract.ts` +- `bun test ./packages/slate-yjs/test` +- `bun --filter ./packages/slate-yjs typecheck` +- `bun --filter ./packages/slate-history typecheck` +- `bun lint:fix` +- `PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium` +- `dev-browser --connect http://127.0.0.1:9222` against + `http://127.0.0.1:3100/examples/yjs-collaboration` for merge undo and + selected replacement undo paths. + +## Notes + +- Package changesets are present for `@slate/yjs` and `slate-history`. +- `pnpm turbo typecheck --filter=...` is blocked in this repo by + `Unsupported package manager specification (bun@1.3.12)`, so package + typechecks used Bun workspace filters. diff --git a/docs/plans/2026-05-27-cmd-a-delete-empty-root.md b/docs/plans/2026-05-27-cmd-a-delete-empty-root.md new file mode 100644 index 0000000000..9d298aaa76 --- /dev/null +++ b/docs/plans/2026-05-27-cmd-a-delete-empty-root.md @@ -0,0 +1,89 @@ +# Cmd+A Delete Empty Root + +## Goal + +Fix the collaboration/demo crash where selecting the whole document with Cmd+A and deleting leaves the initiating editor with an empty root, then clicking or undoing throws. + +## Status + +- [x] Reproduced in dev-browser on the Yjs collaboration example. +- [x] Added a failing core normalization test for `Editor.replace(...children: [])`. +- [x] Fix empty editor root normalization. +- [x] Add browser regression for real keyboard Cmd+A + Backspace + Undo. +- [x] Verify with focused unit, e2e, typecheck, and lint. + +## Findings + +- Initiating peer becomes `children=[]`/empty DOM after Cmd+A Backspace. +- Remote peers render an empty paragraph/placeholder, so peers structurally diverge until the initiator interacts again. +- Clicking/undoing the initiating editor then throws: + - `Cannot get the start point in the node at path [] because it has no start text node.` + - `Cannot read properties of undefined (reading 'text')` +- Existing normalizer repairs empty non-editor elements but not an empty editor root. +- `Editor.replace` mutates snapshot children without operations, so transaction normalization needs an explicit replace-root pass. + +## Changeset + +Published package code under `packages/` changes. Add a changeset unless repo policy says this package path is exempt. + +Added `.changeset/fix-empty-editor-root-normalization.md` for `slate`. + +## Verification + +- `bun test ./packages/slate/test/normalization-contract.ts` passed. +- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun playwright test playwright/integration/examples/yjs-collaboration.test.ts -g "keeps peers usable"` passed across Chromium, Firefox, mobile, and WebKit. +- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- --project=chromium playwright/integration/examples/yjs-collaboration.test.ts` passed with one existing flaky retry in the mixed mark/text insertion case. +- `bun --filter slate typecheck` passed. +- `bun lint:fix` passed with no fixes applied. +- dev-browser 9222 manual replay passed: Cmd+A Backspace produced one empty paragraph per peer, clicking A and pressing Cmd+Z restored `Hello world!` on all peers with no page errors. +- Captured the learning in `docs/solutions/runtime-errors/slate-empty-root-normalization-2026-05-27.md`. + +## Follow-up: placeholder/focus polish + +- User reported Cmd+A Backspace looked like it lost focus and the placeholder moved to a strange position. +- dev-browser showed focus and selection stayed in the editor, but the placeholder was positioned against the editor root instead of the empty paragraph: + - `topDelta: 8` + - `widthDelta: 24` +- The deltas matched the editor padding (`8px 12px 18px`). +- Fixed the Yjs example `renderElement` output so rendered block elements are positioned containers for the placeholder. +- Extended the Cmd+A deletion e2e case to assert focus remains in the editor and the placeholder aligns with the empty paragraph. +- Verification: + - Red test reproduced the bad deltas before the fix. + - `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- --project=chromium playwright/integration/examples/yjs-collaboration.test.ts -g "keeps peers usable"` passed. + - `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- playwright/integration/examples/yjs-collaboration.test.ts -g "keeps peers usable"` passed across Chromium, Firefox, mobile, and WebKit. + - dev-browser 9222 replay showed focus true, paragraph/placeholder deltas all `0`, and no page errors. + - `bun typecheck:site` passed. + - `bun --filter slate typecheck` passed. + - `bun lint:fix` passed with no fixes applied. + +## Follow-up: single-line focus loss + +- User reported multi-paragraph Cmd+A Backspace kept focus, while single-line + Cmd+A Backspace lost usable focus. +- Video transcript helper was unavailable because no `GEMINI_API_KEY` or + `GOOGLE_API_KEY` was configured, so frames and browser replay were used. +- Chromium did not expose the failure consistently after the placeholder fix. +- Firefox/WebKit reproduced the real failure: + - `document.activeElement` stayed on the editor. + - `getSelection().rangeCount` became `0` after Backspace. + - Continuing to type no-oped. +- The same failure reproduced in `custom-placeholder`, so the bug is + `slate-react`, not Yjs. +- Root cause: `applyFullBlockDeleteFragment` removed the fully selected final + block and relied on root normalization to insert an empty paragraph, but did + not restore a collapsed model selection into that normalized block. +- Fix: after deleting all top-level root blocks, set selection to `[0, 0]:0`. + Do not insert a second paragraph here; the core root normalizer already owns + the empty-root repair. +- Added a `slate-react` changeset: + `.changeset/fix-single-line-select-all-focus.md`. +- Verification: + - Red: `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- --project=firefox playwright/integration/examples/yjs-collaboration.test.ts -g "single-line select-all"` failed with peer text stuck at `Write...`. + - Green: same Firefox focused test passed. + - Cross-browser: `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- playwright/integration/examples/yjs-collaboration.test.ts -g "selecting all|single-line select-all"` passed, 8/8. + - dev-browser 9222 replay passed: after Backspace `rangeCount=1`, focus stayed active, typing `2` synced to all four peers. + - `bun --filter slate-react typecheck` passed. + - `bun typecheck:site` passed. + - `bun test ./packages/slate/test/normalization-contract.ts` passed, 15/15. + - `bun --filter slate typecheck` passed. + - `bun lint:fix` passed; first run formatted one file, final rerun applied no fixes. diff --git a/docs/plans/2026-05-27-yjs-collaboration-soak.md b/docs/plans/2026-05-27-yjs-collaboration-soak.md new file mode 100644 index 0000000000..31773bcf5d --- /dev/null +++ b/docs/plans/2026-05-27-yjs-collaboration-soak.md @@ -0,0 +1,50 @@ +# Yjs collaboration soak + +## Goal + +Use `dev-browser` against the local `yjs-collaboration` example to simulate +normal multi-user collaborative editing for about two hours. Record runtime +errors, convergence failures, stale presence, and suspicious user-visible state. + +## Scope + +- Browser: persistent debug Chrome at `http://127.0.0.1:9222`. +- Target: `http://127.0.0.1:3100/examples/yjs-collaboration`. +- Duration: about 2 hours. +- Frequency: low-frequency edits so this resembles human collaboration rather + than a stress fuzzer. +- No code fixes in this pass. + +## Scenario Mix + +- Connected typing in different peers. +- Short selections and mark toggles. +- Paragraph insertions and deletions. +- Occasional undo/redo. +- Brief single-peer disconnect/reconnect windows with local edits. +- Periodic snapshot checks across all peers. + +## Recording + +- Harness script: `.tmp/yjs-collab-soak/soak-runner.mjs` +- Log: `.tmp/yjs-collab-soak/soak.log` +- Summary: `.tmp/yjs-collab-soak/summary.json` + +## Status + +- [x] Prepare harness. +- [x] Start local demo / confirm existing server. +- [x] Run soak. +- [x] Summarize findings. + +## Notes + +- 60s dry run completed with 8 iterations and no collaboration anomalies. +- The harness records peer debug lines from each peer card, not only editor text. +- The page currently emits one `403 Forbidden` resource console error on load; keep it in the log but classify separately from collaboration behavior unless it correlates with editor failure. +- First long run reached iteration 80 with no collaboration anomalies, then hit a harness-only selection race at iteration 81 (`Peer a paragraph 1 not found`). The runner now retries selection and clamps the paragraph index against the current DOM before restarting the soak. +- Accelerated 3m dry run with a low reset threshold completed 32 iterations with no anomalies, covering repeated reset/undo/redo/disconnect cycles. +- Formal 2h run completed from `2026-05-26T17:30:07.686Z` to `2026-05-26T19:30:12.845Z`. +- Formal run covered 151 iterations, 30 snapshots, repeated connected edits, selection/mark toggles, undo/redo, and brief peer disconnect/reconnect windows. +- Formal run result: 0 collaboration anomalies, 0 browser page errors, 6 console resource errors. All 6 console errors were the same `Failed to load resource: the server responded with a status of 403 (Forbidden)` message and did not correlate with editor divergence or runtime failures. +- Final peer state: all peers connected and converged on `Hello world!`. diff --git a/docs/plans/2026-05-27-yjs-merge-node-canonical-late-peer.md b/docs/plans/2026-05-27-yjs-merge-node-canonical-late-peer.md new file mode 100644 index 0000000000..d744f661d5 --- /dev/null +++ b/docs/plans/2026-05-27-yjs-merge-node-canonical-late-peer.md @@ -0,0 +1,57 @@ +# Yjs Merge Node Canonical Late Peer + +## Goal + +Fix `@slate/yjs` so a real Backspace merge keeps Yjs conflict-safe shared text +identity without exposing adjacent compatible text leaves to `readSlateValueFromYjs` +or late-joining peers. + +## Status + +- [x] Read task source and relevant prior solutions. +- [x] Add failing TDD coverage for late peer canonical value after text merge. +- [x] Fix the slate-yjs read/path mapping ownership boundary. +- [x] Run package tests, typecheck, lint, and browser e2e verification. + +## Findings + +- Existing merge reconnect fix intentionally preserves separate `Y.XmlText` + containers to avoid same-offset conflicts with remote inserts. +- Current read/bootstrap path exposes those separate containers as adjacent + compatible Slate text leaves. +- `readSlateValueFromYjs` must not globally merge metadata leaves inside one + `Y.XmlText`; existing mark/selection tests rely on those metadata boundaries. +- The likely seam is Yjs child-boundary reading/mapping, not the structural + merge encoder that preserves CRDT identity. +- Red test failed on `readSlateValueFromYjs(root)`: actual `alpha` + `beta` + adjacent leaves instead of canonical `alphabeta`. +- First read-only fix broke existing concurrent insert coverage because operation + replay started using canonical paths and fell back to snapshot replacement. +- Final design keeps raw Yjs leaf paths for operation replay and uses virtual + merged paths for read/selection mapping. +- Remote history repair must also read raw Yjs child-boundary paths. Feeding it + the canonical merged read leaves stale merge split positions and makes offline + Backspace undo split `alpha!beta` into `alpha` / `!beta`. +- `docs/solutions/patterns/critical-patterns.md` is absent in this repo. +- Captured reusable learning in + `docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md`. + +## Changeset + +Updated `.changeset/slate-yjs-structural-reconnect.md` for `@slate/yjs` patch. + +## Verification + +- Red: `bun test ./packages/slate-yjs/test/core-contract.ts -t "canonical Slate text"` failed before the fix. +- Green: `bun test ./packages/slate-yjs/test/core-contract.ts -t "canonical Slate text"` passed. +- `bun test ./packages/slate-yjs/test/core-contract.ts` passed, 47/47. +- `bun --filter @slate/yjs test` passed, 47/47. +- `bun --filter @slate/yjs typecheck` passed. +- `bun lint:fix` passed; final run applied no fixes. +- Focused browser regression: + `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- --project=chromium playwright/integration/examples/yjs-collaboration.test.ts -g "undoes offline Backspace merge"` passed. +- Real persistent dev-browser Chrome at `http://127.0.0.1:9222` passed all 25 + `yjs-collaboration` e2e scenarios in five batches: 1-5, 6-10, 11-15, 16-20, + 21-25. +- Official Chromium e2e: + `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 bun run playwright -- --project=chromium playwright/integration/examples/yjs-collaboration.test.ts` passed, 25/25. diff --git a/docs/plans/2026-05-28-yjs-command-matrix-controls.md b/docs/plans/2026-05-28-yjs-command-matrix-controls.md new file mode 100644 index 0000000000..f1350f016e --- /dev/null +++ b/docs/plans/2026-05-28-yjs-command-matrix-controls.md @@ -0,0 +1,22 @@ +# Yjs Command Matrix Controls + +Expose the collaboration example as a command playground, not just a narrow +demo. The UI should let a tester trigger the visible Slate/Yjs operation +families from buttons. Automated e2e stays focused on concrete collaboration +regressions, not broad UI-control inventory. + +## Matrix + +- [x] Text: insert, delete, delete backward, delete forward. +- [x] Break: insert break, insert soft break. +- [x] Fragment: insert fragment, delete fragment. +- [x] Nodes: insert, remove, split, merge, move, set, unset, wrap, unwrap, lift. +- [x] Existing controls: marks, blocks, replace, selection, undo, redo, + reconcile, connect/disconnect. + +## Verification + +- [x] Do not add generic Playwright rows for button presence or button inventory. +- [x] Keep e2e focused on concrete collaboration cases. +- [x] Run the full Yjs example suite. +- [x] Use dev-browser for a real browser pass after implementation. diff --git a/docs/plans/2026-05-28-yjs-potion-parity-regressions.md b/docs/plans/2026-05-28-yjs-potion-parity-regressions.md new file mode 100644 index 0000000000..0ca815c7ec --- /dev/null +++ b/docs/plans/2026-05-28-yjs-potion-parity-regressions.md @@ -0,0 +1,54 @@ +# Yjs Potion parity regressions + +## Goal + +Fix only collaboration behaviors where Potion disagrees with the local +`@slate/yjs` implementation, using failing-first tests. + +## Scope + +- [x] Split Node: offline split + concurrent insert + B undo must converge like + Potion. +- [x] Merge Node: offline merge + concurrent append may drop the append because + Potion also does, but B undo must not diverge. +- [x] Wrap Node: offline wrap + concurrent text insert must preserve the insert. +- [x] Insert Fragment: offline fragment insert + concurrent append must preserve + both edits. +- [x] Move Down: no fix in this slice; Potion also drops the concurrent insert. + +## Release Artifact + +- Added `.changeset/slate-yjs-potion-parity.md` for the package behavior fix. + +## TDD Plan + +1. Add the smallest failing package tests for the four parity cases. +2. Fix the shared Yjs operation/history boundary, not the demo buttons. +3. Run targeted `@slate/yjs` tests after each green slice. +4. Run package typecheck and lint fix before handoff. + +## Verification + +- `bun test ./packages/slate-yjs/test` -> 53 pass. +- `bun --filter ./packages/slate-yjs typecheck` -> pass. +- `bun typecheck:root` -> pass. +- `bun lint:fix` -> pass. +- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium -g "offline wrap button|offline insert fragment"` -> 2 pass. +- Captured reusable learning in `docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md`. +- Follow-up full e2e stabilization: fixed `placePeerCaret` to use a + textbox-scoped Playwright click and fixed the example so each simulated + peer's `Y.Doc.clientID` matches its displayed client id. +- Follow-up verification: + `PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium` + -> 28 pass. +- Captured reusable learning in + `docs/solutions/test-failures/yjs-example-client-id-determinism-2026-05-28.md`. + +## Notes + +- Potion evidence from `felixfeng33@gmail.com` profile: + - Split undo: Potion converges to `alph!abeta`, no B-only fork. + - Merge undo: Potion converges to `alpha / beta`, no B-only fork. + - Wrap: Potion preserves `alpha!` inside the blockquote. + - Insert Fragment: Potion converges to `alpha AdaLin fragment`. + - Move Down: Potion drops `!`, same as local. diff --git a/docs/plans/2026-05-28-yjs-public-split-api.md b/docs/plans/2026-05-28-yjs-public-split-api.md new file mode 100644 index 0000000000..07b8703df0 --- /dev/null +++ b/docs/plans/2026-05-28-yjs-public-split-api.md @@ -0,0 +1,24 @@ +# Yjs Public Split API Fix + +## Goal + +Fix `tx.nodes.split({ at: Point })` in `@slate/yjs` so public Slate v2 +transaction API users get the same collaboration semantics as keyboard Enter. + +## Plan + +- [x] Check prior Yjs split/history solution notes. +- [ ] Add a failing core test for public `tx.nodes.split({ at })` export. +- [ ] Fix Yjs split operation replay/export. +- [ ] Add or update focused Playwright coverage for offline split, concurrent + insert, reconnect, undo. +- [ ] Run focused package/browser verification. + +## Findings + +- Pure Slate `tx.nodes.split({ at: { path: [0, 0], offset: 4 } })` already + produces `alph / abeta`. +- The broken path is `@slate/yjs` exporting the resulting split operations into + the shared Yjs tree. +- Existing split solution says split encoders must keep original `Y.XmlText` + identity alive and avoid destructive tail replacement. diff --git a/docs/solutions/developer-experience/yjs-awareness-react-hooks-2026-05-29.md b/docs/solutions/developer-experience/yjs-awareness-react-hooks-2026-05-29.md new file mode 100644 index 0000000000..6aaa336720 --- /dev/null +++ b/docs/solutions/developer-experience/yjs-awareness-react-hooks-2026-05-29.md @@ -0,0 +1,100 @@ +--- +title: Yjs awareness React hooks need explicit external-store subscriptions +date: 2026-05-29 +category: docs/solutions/developer-experience +module: slate-yjs +problem_type: developer_experience +component: tooling +symptoms: + - Remote cursor state changed without a Slate document commit. + - React hooks could read stale awareness data if they subscribed only to Slate commits. + - Generic `Editor` reads did not expose `state.yjs` to TypeScript. +root_cause: incomplete_setup +resolution_type: code_fix +severity: medium +tags: [slate-yjs, awareness, react, external-store, cursor] +--- + +# Yjs awareness React hooks need explicit external-store subscriptions + +## Problem + +Yjs awareness is presence state, not document state. Remote cursor changes can +arrive without any Slate operation, so React hooks that depend only on editor +commit subscriptions miss updates. + +## Symptoms + +- `tx.yjs.sendSelection(...)` updates awareness but must not add a Yjs document + trace entry. +- Remote awareness changes need to re-render cursor UI even when the Slate value + is unchanged. +- A `@slate/yjs/react` hook reading `state.yjs` from a generic `Editor` fails + package typecheck because extension groups are not visible on that generic + state view. + +## What Didn't Work + +- Treating cursor movement as a document commit would make presence pollute + collaboration history. +- Relying on Slate React commit selectors alone misses awareness changes that + bypass the editor transaction pipeline. +- Reading `state.yjs` directly from `EditorCoreStateView` in a public hook does + not typecheck for callers whose editor type does not encode installed + extensions. + +## Solution + +Keep awareness state outside the document model, but expose a small subscription +surface from the Yjs controller: + +```ts +state.yjs.awarenessRevision() +state.yjs.subscribeAwareness(listener) +state.yjs.remoteCursors() +``` + +The controller increments the revision and notifies subscribers from the +awareness `change` listener, plus local connect/disconnect changes. The React +entry then uses `useSyncExternalStore`: + +```ts +export function useYjsAwarenessRevision(editor: Editor) { + return useSyncExternalStore( + (listener) => + readYjsState(editor, (state) => state.subscribeAwareness(listener)), + () => getYjsAwarenessRevision(editor), + () => getYjsAwarenessRevision(editor) + ) +} +``` + +The hook reads `state.yjs` through a local typed accessor so the public API can +accept a plain `Editor` while keeping the extension-specific cast contained in +`@slate/yjs/react`. + +## Why This Works + +Awareness traffic has its own lifecycle and should re-render presence UI without +touching Slate history or Yjs document traces. `useSyncExternalStore` matches +that lifecycle: React subscribes to the controller's awareness revision, and +render reads the latest resolved remote cursors from `state.yjs`. + +The typed accessor avoids leaking extension-installation generics into every +hook call. The runtime still requires the Yjs extension; the type compromise is +local and explicit. + +## Prevention + +- Presence hooks should subscribe to awareness events, not only Slate document + commits. +- Keep cursor payloads as Yjs relative-position JSON and resolve them at read + time so they rebase through document edits. +- Add package tests that assert awareness changes do not write operation trace, + disconnected peers expose no remote cursors, and remote cursor selections + rebase through moved-node identity. + +## Related Issues + +- `docs/solutions/test-failures/yjs-example-client-id-determinism-2026-05-28.md` +- `docs/solutions/ui-bugs/slate-react-selection-export-must-respect-other-editor-roots-2026-05-25.md` diff --git a/docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md b/docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md new file mode 100644 index 0000000000..aacb552310 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md @@ -0,0 +1,113 @@ +--- +title: Preserve concurrent edits through Yjs Backspace merge normalization +date: 2026-05-25 +last_updated: 2026-05-29 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - A disconnected peer merges two paragraphs with Backspace. + - A connected peer inserts text into the surviving left paragraph. + - Reconnecting converges to the merged text without the connected peer's insert. + - Cross-block deleteFragment can drop remote text inserted into the absorbed end block. +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, merge-node, reconnect, playwright] +--- + +# Preserve concurrent edits through Yjs Backspace merge normalization + +## Problem +The Yjs collaboration example could still drop a connected peer's insert when a +disconnected peer merged two paragraphs with Backspace and then reconnected. + +## Symptoms +- A writes `alpha` / `beta`. +- B disconnects. +- B places the caret at the start of `beta` and presses Backspace. +- A inserts `!` after `alpha`. +- B reconnects. +- All peers converge to `alphabeta` instead of `alpha!beta`. +- In the harder deleteFragment shape, B deletes from `alpha` into `gamma`, + A appends `!` to `gamma`, and reconnect converges to `almma` instead of + `almma!`. + +## What Didn't Work +- Testing only a single element `merge_node` was too narrow. It proved the + structural merge, but not the real Backspace operation batch. +- Merging the right text into the left `Y.XmlText` looked closer to Slate's final + value, but it makes the right text and a concurrent remote insert compete at + the same Yjs offset. +- Browser proof was initially misleading because the dev site resolved + `@slate/yjs` through stale package `dist` instead of the edited source. + +## Solution +Handle the real Backspace batch: + +```ts +[ + { type: 'merge_node', path: [1], position: 1 }, + { type: 'merge_node', path: [0, 1], position: 5 }, +] +``` + +The element merge must not clone the right paragraph's children. Insert +traceable virtual placeholders into the surviving paragraph and hide the +absorbed paragraph so the original right-side `Y.XmlText` remains in the Yjs +document: + +```ts +insertYjsChild( + root, + previous, + getYjsLength(previous), + createVirtualYjsMovePlaceholder(child) +) +hideYjsNode(target) +``` + +The follow-up text normalization merge returns success when the previous and +current text leaves live in different `Y.XmlText` containers: + +```ts +if (previousSharedLeaf && previousSharedLeaf.sharedText !== leaf.sharedText) { + return ( + PathApi.equals(previousPath.slice(0, -1), operation.path.slice(0, -1)) && + operation.position === previousSharedLeaf.text.length + ) +} +``` + +That prevents the operation batch from falling back to whole-document snapshot +replacement while keeping both Yjs text containers alive for conflict merging. +The current trace name for the element merge path is +`fallback: "virtual-merge-ref"`. + +The demo also needs Turbopack aliases based on package names, including scoped +subpath entries such as `@slate/yjs/react`, so browser tests exercise source +files instead of stale `dist`. + +## Why This Works +Yjs preserves concurrent edits when they stay attached to live shared types. In +this case, the remote `!` can belong to either the original left `Y.XmlText` or +the absorbed right `Y.XmlText`. The merged text can remain in separate adjacent +`Y.XmlText` containers and still render as one Slate paragraph. Yjs avoids +same-offset insert ordering and absorbed-block data loss that would otherwise +produce `alphabeta!`, `almma`, or force a snapshot fallback. + +## Prevention +- Add tests for real user operation batches, not just the first structural op. +- Include cross-block `deleteFragment` rows where the remote edit targets the + absorbed end block, not only the surviving start block. +- Treat a supported operation encoder returning `false` as dangerous; it can + silently trigger snapshot fallback. +- For Yjs text merges, preserve live shared-type identity unless a test proves + same-container insertion is conflict-safe. +- Keep dev-site workspace aliases keyed by package name and subpath, not by + folder name only. + +## Related Issues +- `docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md` diff --git a/docs/solutions/logic-errors/yjs-forward-move-history-fallback-2026-05-26.md b/docs/solutions/logic-errors/yjs-forward-move-history-fallback-2026-05-26.md new file mode 100644 index 0000000000..7482b1a7f5 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-forward-move-history-fallback-2026-05-26.md @@ -0,0 +1,63 @@ +--- +title: Encode move_node through visible destination slots +date: 2026-05-26 +last_updated: 2026-05-29 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Keyboard undo after a reconnected offline block move changed only the initiating editor + - Other connected peers stayed on the moved order after Cmd+Z + - Reconcile snapped the initiating editor back to the remote Yjs order +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, move-node, undo-redo, playwright] +--- + +# Encode move_node through visible destination slots + +## Problem +After an offline peer moved a block, reconnected, and pressed keyboard Undo, the initiating editor showed the undone order while the other peers stayed on the moved order. + +## Symptoms +- Reconnect converged all peers to `beta / alpha / gamma!`. +- Keyboard Undo on B changed B to `alpha / beta / gamma!`. +- A/C/D stayed at `beta / alpha / gamma!`. +- B Reconcile restored B to `beta / alpha / gamma!`, proving B's Yjs document had not encoded the local undo. + +## What Didn't Work +- Checking the button path alone was misleading. The Undo button used the Yjs history stack and converged, while keyboard Undo fell back to operation replay. +- Treating the browser event path as the root cause missed the real failure: the fallback `move_node` encoder accepted the operation but produced no visible Yjs value change. +- Waiting longer did not help. The split was stable because the wrong Yjs tree state had already been exported. + +## Solution +Represent moved nodes with a virtual placeholder at the destination and keep the original Yjs node hidden in place: + +```ts +const placeholder = createVirtualYjsMovePlaceholder(target) + +insertYjsChild(root, newParent, operation.newPath.at(-1)!, placeholder) +``` + +The regression should cover both layers: + +- Core: `move_node [0] -> [2]` encodes `beta / gamma / alpha` while `alpha` remains the same `Y.XmlElement`. +- Browser: offline move, reconnect, keyboard Undo, keyboard Redo, assert every peer converges. + +## Why This Works +`@slate/yjs` cannot treat a moved subtree as disposable if a remote peer may still edit the original shared type. The virtual placeholder gives the destination a visible slot that resolves back to the same hidden source node. Destination insertion is computed against visible child slots, so hidden sources and placeholders do not corrupt same-parent forward indexes. + +Undo and redo then work through Yjs: undo removes the placeholder and restores the hidden source; redo hides the source and restores the placeholder. Remote text remains attached to the same Yjs node through both directions. + +## Prevention +- Test same-parent and cross-parent `move_node` operations with direct identity assertions against the visible Yjs node. +- Browser collaboration tests should exercise keyboard history, not just toolbar buttons. +- When a local editor changes but Reconcile restores the remote value, inspect the Yjs document state before debugging UI rendering. +- Do not clone a moved subtree when the operation needs collaboration conflict resolution. Use a traceable virtual placeholder or explicitly mark the shape unsupported. + +## Related Issues +- `docs/solutions/ui-bugs/yjs-user-history-button-routing-2026-05-25.md` +- `docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md` diff --git a/docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md b/docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md new file mode 100644 index 0000000000..474c8fb194 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md @@ -0,0 +1,112 @@ +--- +title: Read Yjs merged text containers as canonical Slate leaves +date: 2026-05-27 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - A real Backspace merge stores adjacent compatible text in separate Yjs containers. + - `readSlateValueFromYjs` returns `alpha` and `beta` as two Slate text leaves after the editor merged them into `alphabeta`. + - Late-joining peers bootstrap with adjacent compatible text leaves. + - Canonical read paths can break Slate-to-Yjs selection mapping when offsets land in the second Yjs container. + - Reusing canonical read paths for history repair can make offline merge undo split concurrent text at the wrong offset. +root_cause: logic_error +resolution_type: code_fix +severity: medium +tags: [slate-yjs, yjs, merge-node, text-leaves, selection] +--- + +# Read Yjs merged text containers as canonical Slate leaves + +## Problem +The Backspace merge encoder keeps the left and right text in separate +`Y.XmlText` containers so concurrent inserts stay attached to live shared types. +That is correct for conflict handling, but a plain read exposed those containers +as adjacent compatible Slate text leaves, so late peers did not match the +canonical editor value. + +## Symptoms +- A local editor applies the real Backspace batch: + +```ts +[ + { type: 'merge_node', path: [1], position: 1 }, + { type: 'merge_node', path: [0, 1], position: 5 }, +] +``` + +- The initiating editor reads `[{ text: 'alphabeta' }]`. +- `readSlateValueFromYjs(root)` and a late peer read + `[{ text: 'alpha' }, { text: 'beta' }]`. +- A naive read fix makes operation replay use canonical paths, so the second + `merge_node` cannot find raw path `[0, 1]` and falls back to snapshot + replacement, dropping concurrent inserts. +- A second naive fix feeds canonical merged children into remote history repair. + That hides the raw split at `[0, 1]`, leaves the merge undo position stale, and + turns `alpha!beta` into `alpha` / `!beta` on undo. + +## What Didn't Work +- Merging the right text into the left `Y.XmlText` would make the stored value + look canonical, but it reintroduces same-offset conflicts with remote inserts. +- Globally merging all adjacent same-mark text leaves during read would destroy + intentional metadata boundaries inside a single `Y.XmlText`. +- Making `getYjsTextLeaves` always return canonical paths breaks structural + operation replay because operation batches still address raw intermediate + Slate paths. + +## Solution +Keep two path views over the same Yjs tree: + +- Raw leaf paths for operation replay and structural encoders. +- Virtual merged leaf paths for `readSlateValueFromYjs` and selection mapping. +- Raw child-boundary reads for remote history repair. + +When reading an element, merge adjacent compatible text only at Yjs child +boundaries. Do not merge metadata leaves produced by the same `Y.XmlText`: + +```ts +appendYjsReadChildren(children, readYjsNode(child, options)) +``` + +For selection mapping, allow one Slate text path to span multiple Yjs text +segments by recording each segment's Slate offset range: + +```ts +{ + path: [...previous.path], + sharedText: child, + slateStart: previous.slateEnd, + slateEnd: previous.slateEnd + text.length, +} +``` + +Then map Slate offsets through the segment range before creating a Yjs relative +position: + +```ts +index: leaf.start + point.offset - leaf.slateStart +``` + +## Why This Works +Yjs conflict safety depends on preserving shared-type identity. Slate +canonicality depends on exposing adjacent compatible text as one leaf. Those are +different views, not competing storage strategies. Raw paths keep the operation +encoder and history repair faithful to Slate's intermediate operation batch; +virtual paths keep read values and selections faithful to the final editor value. + +## Prevention +- Add regressions that assert both `readSlateValueFromYjs` and late peer + bootstrap after real Backspace merge batches. +- Include a Slate-to-Yjs-to-Slate range round trip at an offset inside the second + Yjs container. +- Keep operation replay tests that prove concurrent inserts survive the same + Backspace merge. +- Keep browser history tests that undo the same offline merge after reconnect. +- Do not make structural encoders consume the same canonical path view used by + read/bootstrap paths. + +## Related Issues +- `docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-text-leaf-metadata-delta-sync-2026-05-26.md` +- `docs/solutions/logic-errors/yjs-offline-merge-stale-undo-2026-05-26.md` diff --git a/docs/solutions/logic-errors/yjs-offline-merge-stale-undo-2026-05-26.md b/docs/solutions/logic-errors/yjs-offline-merge-stale-undo-2026-05-26.md new file mode 100644 index 0000000000..5bbc24df30 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-offline-merge-stale-undo-2026-05-26.md @@ -0,0 +1,91 @@ +--- +title: Repair offline merge undo after Yjs reconnect +date: 2026-05-26 +last_updated: 2026-05-26 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Offline Backspace merge reconnects to the correct shared text + - Keyboard Undo on the offline peer can no-op or split at an old text offset + - Potion keeps the concurrent insert when undoing the offline merge +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, merge-node, undo-redo, playwright] +--- + +# Repair offline merge undo after Yjs reconnect + +## Problem +A disconnected peer can merge two paragraphs with Backspace while another peer inserts into the surviving left text. Reconnect converges correctly to `alpha!beta`, but the disconnected peer's local Slate undo history can still hold the old merge split point. The correct undo is not a stale no-op; it should undo the local merge and preserve the remote insert as `alpha! / beta`. + +## Symptoms +- A writes `alpha` / `beta`. +- B disconnects and presses Backspace at the start of `beta`, locally producing `alphabeta`. +- A inserts `!` after `alpha`. +- B reconnects and all peers converge to `alpha!beta`. +- B presses keyboard Undo. +- Broken behavior: undo no-ops or splits at the old offset. +- Expected behavior: all peers become `alpha!` / `beta`. + +## What Didn't Work +- Fixing only the Yjs merge encoder made reconnect converge, but left Slate's local history stack pointing at the pre-remote-edit split position. +- Dropping the stale merge batch avoided bad undo, but it made the user's local merge impossible to undo even though Potion could preserve the remote insert. +- Letting the historic Slate commit fall back to snapshot export turned a local stale replay into shared Yjs state. +- Treating the result as a button issue missed the fact that keyboard Undo used the same stale Slate history batch. + +## Solution +During remote import history repair, rebase a text-level `merge_node` history batch to the current previous text leaf length: + +```ts +const repairTextMergeHistoryOperation = ( + operation: Extract, + value: Value +) => { + const node = getTextNode(value, operation.path) + + if (!node) { + return true + } + + const slateIndex = operation.path.at(-1) + + if (slateIndex === undefined || slateIndex <= 0) { + return true + } + + const previousNode = getTextNode(value, [ + ...operation.path.slice(0, -1), + slateIndex - 1, + ]) + const previousText = + typeof previousNode?.text === 'string' ? previousNode.text : null + + if (previousText == null) { + return true + } + + operation.position = previousText.length + + return true +} +``` + +The Playwright regression should press the real keyboard undo shortcut after reconnect and assert every peer becomes `alpha! / beta`. + +## Why This Works +Yjs keeps the concurrent insert attached to the live left text, so reconnect can converge to `alpha!beta`. Slate history stores a concrete `merge_node.position`; after the remote `!` lands, the old split point no longer represents the boundary between the user's two original paragraphs. Rewriting the history position to the current previous text length preserves the user's intent: undo my merge, not the remote insert. + +## Prevention +- Add browser coverage for reconnect followed by real keyboard Undo, not just reconnect convergence. +- Treat stale structural history after remote import as repairable only when its semantic target still exists in the converged value. +- If `applyYjsHistory()` rejects a historic commit and Slate fallback would export a divergent replay, inspect the local history batch before changing Yjs conflict handling. +- Use Potion as a differential oracle for offline structural undo. If Potion preserves a remote insert, dropping the local undo is probably too weak. + +## Related Issues +- `docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-forward-move-history-fallback-2026-05-26.md` +- `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md` diff --git a/docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md b/docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md new file mode 100644 index 0000000000..e80a181499 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md @@ -0,0 +1,93 @@ +--- +title: Preserve offline split-node edits through reconnect merges +date: 2026-05-25 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Multiple disconnected peers edit the same initial document + - A text replacement disappears after reconnect + - A block split brings back a duplicate stale paragraph +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, split-node, reconnect, playwright] +--- + +# Preserve offline split-node edits through reconnect merges + +## Problem +The Yjs collaboration demo could lose a disconnected peer's text replacement +and duplicate another peer's stale paragraph when peers reconnected after mixed +offline edits. + +## Symptoms +- Peer B disconnects and bolds `Hello`. +- Peer C disconnects and replaces `Hello` with `Hi`. +- Peer D disconnects, presses Enter at the end, and types `Test`. +- Reconnecting B, C, and D converges all peers to + `Hello world!TestHello world!` instead of two paragraphs: + `Hi world!` and `Test`. + +## What Didn't Work +- Retesting only append/undo flows missed the issue because those edits already + used operation-level Yjs writes. +- Treating the bug as a demo timing issue was wrong. The failure reproduced + with real keyboard input and deterministic Playwright steps. +- Allowing unsupported structural operations to fall back to full-document + snapshot writes made the local value look correct while sending destructive + Yjs deletes on reconnect. + +## Solution +Encode Slate `split_node` operations directly in the Yjs tree. + +Text splits update `slate:text-leaves` metadata on the existing `Y.XmlText` so +the original shared text container stays alive: + +```ts +leaves.splice( + leafIndex, + 1, + { ...textLeaf, text: textLeaf.text.slice(0, operation.position) }, + { + ...(operation.properties as Record), + text: textLeaf.text.slice(operation.position), + } +) +setYjsTextLeaves(leaf.sharedText, leaves) +``` + +Element splits keep the left side in the original `Y.XmlElement`, create the +right sibling, and move the split text-leaf tail into that sibling without +replacing the whole root. + +Add Playwright coverage for the browser-visible path: + +```ts +B offline -> bold Hello +C offline -> type Hi over Hello +D offline -> Enter -> Test +B/C/D reconnect +assert paragraphs are ['Hi world!', 'Test'] +``` + +## Why This Works +Yjs can merge concurrent edits when they target live shared types. The old +fallback deleted and reinserted the document tree for D's Enter key, so C's +replacement targeted a container that D had effectively replaced. Keeping the +original text container alive lets C's `Hello` -> `Hi` edit merge with D's new +paragraph instead of being overwritten by D's stale snapshot. + +## Prevention +- Add operation-level Yjs encoders before accepting snapshot fallback for user + editing operations. +- Browser tests for collaboration should mix mark, text, and structural edits; + append-only tests are not enough. +- Assert paragraph arrays, not just flattened text, when testing reconnect + merges involving Enter or paste. + +## Related Issues +- `docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md` +- `docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md` +- `docs/solutions/ui-bugs/yjs-user-history-button-routing-2026-05-25.md` diff --git a/docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md b/docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md new file mode 100644 index 0000000000..713c2cdde0 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md @@ -0,0 +1,74 @@ +--- +title: Preserve Yjs split positions and clean split history undo +date: 2026-05-26 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Concurrent inserts after offline split_node land at the start of the document + - Undo after offline split-at-end typing leaves an empty paragraph + - Rapid history button replay gives remote peers taller empty paragraphs +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, slate-history, split-node, undo-redo, reconnect] +--- + +# Preserve Yjs split positions and clean split history undo + +## Problem +Offline `split_node` handling broke Yjs positional intent when another peer inserted into the original text while the splitter was disconnected. The same split/history path also left empty text leaves behind during undo/redo, which made remote peer DOM height diverge. + +## Symptoms +- B disconnects, splits `alphabeta` at `alpha|beta`, A inserts `!` at offset 2, 5, or 7, then B reconnects. The local result becomes `!alpha / beta` instead of preserving the insertion around the split. +- B disconnects, presses Enter at the end of `alpha`, types `beta`, A inserts `!`, then B reconnects and presses Cmd+Z. The local result leaves `alpha!` plus an empty paragraph. +- Pressing Enter three times and rapidly replaying Undo/Redo through the example buttons leaves remote peers with taller empty paragraphs. + +## What Didn't Work +- Comparing only logical paragraph text hid the remote DOM issue. The peers converged to the same visible text while remote paragraphs still contained extra empty text leaves. +- Preventing the Undo/Redo buttons from stealing focus helped align them with the other user controls, but it did not fix the structural height bug by itself. +- Treating split reconnect as a snapshot problem missed the lower-level issue: the split code was still deleting and reinserting the original `Y.XmlText` tail. + +## Solution +Keep the original Yjs text identity alive when splitting. `splitYjsTextAtLeafIndex` should delete only the tail after the split point, then clone the right side into the new block: + +```ts +const beforeTextLength = before.reduce( + (length, leaf) => length + leaf.text.length, + 0 +) + +if (beforeTextLength < sharedText.length) { + sharedText.delete(beforeTextLength, sharedText.length - beforeTextLength) +} +setYjsTextLeaves(sharedText, before.length > 0 ? before : [{ text: '' }]) +``` + +Teach Slate history that word typing into the paragraph created by the immediately previous split batch is part of that undo unit. Keep punctuation follow-up edits separate. + +When applying text `merge_node` across different Yjs text containers, delete the merged leaf if it is empty: + +```ts +if (leaf.text.length === 0) { + leaf.sharedText.setAttribute(DELETED_ATTRIBUTE, 'true') +} +``` + +Also keep example Undo/Redo buttons from taking focus on `mousedown`, matching the other user-facing controls. + +## Why This Works +Yjs can only rebase concurrent text inserts correctly when the original shared items survive. Full delete/reinsert turns a split into a destructive replacement, so concurrent inserts anchored inside the original text drift to the front of the replacement. + +The undo issue had two layers. First, history treated Enter and subsequent word typing as separate undo units, so one undo removed only the word. Second, structural merge cleanup cloned empty text leaves into the surviving block but never removed them, so redo rebuilt paragraphs with multiple empty leaves and inflated height. + +## Prevention +- For split and merge encoders, preserve live Yjs containers unless a test proves replacement semantics are safe. +- Add core tests for disconnected split with concurrent insert offsets before adding browser-only coverage. +- Browser tests for collaboration history should assert layout metrics, not only visible text. +- Keep a package test entrypoint that the repo's default Bun test command actually discovers. + +## Related Issues +- `docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md` +- `docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md` +- `docs/solutions/ui-bugs/yjs-user-history-button-routing-2026-05-25.md` diff --git a/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md b/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md new file mode 100644 index 0000000000..ee793c9d13 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md @@ -0,0 +1,89 @@ +--- +title: Preserve Yjs identity through structural wrap, unwrap, and fragment edits +date: 2026-05-28 +last_updated: 2026-05-29 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Offline wrap_node drops a remote insert inside the wrapped node after reconnect. + - Offline unwrap_node drops a remote insert inside the unwrapped node after reconnect. + - Offline insert fragment drops a remote append at the same text position after reconnect. + - Offline merge undo can leave the initiating editor split from the shared Yjs value. +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, wrap-node, unwrap-node, insert-fragment, undo-redo] +--- + +# Preserve Yjs identity through structural wrap, unwrap, and fragment edits + +## Problem +Some offline structural edits were encoded by cloning visible Yjs nodes and hiding the originals. That made reconnect look locally correct, but concurrent remote text stayed attached to hidden containers and disappeared from the visible Slate value. + +## Symptoms +- B goes offline, wraps the first block, A inserts `!` into that block, then B reconnects. Broken result: the wrapped block reads `alpha`; expected `alpha!`. +- B goes offline, unwraps a virtual-wrapped block, A inserts `!` inside the wrapped block, then B reconnects. Broken result: peers can see an empty wrapper or lose the remote insert; expected one unwrapped paragraph reading `alpha!`. +- B goes offline, inserts `Lin fragment` at the end of `alpha`, A appends ` Ada`, then B reconnects. Broken result: `alphaLin fragment`; expected `alpha AdaLin fragment`. +- B goes offline, merges `alpha / beta`, A appends to `beta`, then B reconnects and undoes. Broken result: B can stay at `alphabeta / empty` while other peers read `alpha / beta`. + +## What Didn't Work +- Treating `wrap_node` as a normal `move_node` clone lost remote edits because the visible clone and hidden original were independent Yjs types. +- Replacing one text child with another for `insert_fragment` hid the original `Y.XmlText`, so remote inserts on the original text were skipped by readers. +- Letting a historic Slate undo commit export without reconciling back from Yjs allowed the local editor to keep a stale Slate replay even when the shared Yjs document had converged. + +## Solution +Keep live Yjs identities in the paths that need CRDT conflict resolution. + +For wrap-like moves, store a reference from the wrapper to the original moved node and hide the original only at its old root position. Read and path lookup helpers treat that referenced source as the wrapper's virtual child. The current first-party package names that fallback `virtual-move-ref`: + +```ts +const SLATE_YJS_ID_ATTRIBUTE = 'slate:yjs-id' +const SLATE_YJS_HIDDEN_ATTRIBUTE = 'slate:yjs-hidden' +const SLATE_YJS_VIRTUAL_CHILD_ID_ATTRIBUTE = 'slate:yjs-virtual-child-id' + +target.setAttribute(SLATE_YJS_ID_ATTRIBUTE, nodeId) +target.setAttribute(SLATE_YJS_HIDDEN_ATTRIBUTE, true) +wrapper.setAttribute(SLATE_YJS_VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) +``` + +`readSlateValueFromYjs(...)` and `getYjsNode(...)` must both resolve visible children through that virtual reference. If only reads do it, later text operations against wrapped paths will miss the original shared node. + +For unwrap, do not apply the wrapper move path to the root. Public `unwrapNodes({ at: [0] })` emits `move_node [0,0] -> [0]` and then `remove_node [1]`. The Yjs encoder should restore the referenced source node, hide the emptied wrapper shell, and let the following remove operation delete that hidden shell: + +```ts +target.removeAttribute(SLATE_YJS_HIDDEN_ATTRIBUTE) +wrapper.removeAttribute(SLATE_YJS_VIRTUAL_CHILD_ID_ATTRIBUTE) +wrapper.setAttribute(SLATE_YJS_HIDDEN_ATTRIBUTE, true) +``` + +That fallback is traceable as `virtual-unwrap-ref` followed by `virtual-unwrap-wrapper-remove`. + +For single-text `replace_children` edits, apply a text diff to the existing `Y.XmlText` instead of hiding it and inserting a replacement container: + +```ts +sharedText.delete(prefixLength, removedLength) +sharedText.insert(insertOffset, text, getNodeAttributes(leaf)) +``` + +For historic commits, after exporting the Slate history replay to Yjs, read the canonical shared value and replace the local editor value when they differ. + +## Why This Works +Yjs can rebase concurrent edits when both peers edit the same shared type. Clone-and-hide is acceptable for some move operations, but not when the hidden source still receives meaningful concurrent text. A virtual wrapper child keeps the original shared node alive for both local wrapped edits and remote updates. Text diffing keeps fragment insertion in the same `Y.XmlText`, so same-offset inserts order by Yjs conflict rules instead of disappearing behind `slate:deleted`. + +The history fix handles the remaining mismatch layer: Slate's local undo replay can be structurally stale, while Yjs has already produced the correct collaborative value. Replacing local Slate state from Yjs after a historic export keeps the initiating peer converged. + +## Prevention +- Add package-level tests for each structural encoder that can hide or clone a Yjs container. +- Characterize public composed transforms by their emitted Slate operations before encoding them. In the current v2 API, `wrapNodes` emits `insert_node` then `move_node`; `unwrapNodes` emits `move_node` then `remove_node`. +- Assert the trace entry for wrapper moves: `mode: "traceable-fallback"`, `fallback: "virtual-move-ref"`. A named fallback is fine; a silent clone is data loss wearing a hat. +- For virtual unwrap, assert source identity directly: the raw `Y.XmlElement` that was hidden under the wrapper must be the same object after unwrap. +- For browser examples, assert final peer text only; do not add style or disabled-state assertions to collaboration e2e tests. +- Treat Potion as a parity oracle only after confirming the same operation shape. Move/down clone loss still matches Potion and should stay out of this fix. +- When a fix needs conflict resolution, preserve the original Yjs shared type or add an explicit virtual reference back to it. + +## Related Issues +- `docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md` +- `docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md` diff --git a/docs/solutions/logic-errors/yjs-text-leaf-metadata-delta-sync-2026-05-26.md b/docs/solutions/logic-errors/yjs-text-leaf-metadata-delta-sync-2026-05-26.md new file mode 100644 index 0000000000..3a66abd140 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-text-leaf-metadata-delta-sync-2026-05-26.md @@ -0,0 +1,129 @@ +--- +title: Reconcile Yjs text deltas with Slate leaf metadata +date: 2026-05-26 +last_updated: 2026-05-29 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Concurrent offline mark edits disappear after reconnect. + - Mark removals can be undone by stale slate:text-leaves metadata. + - Versioned same-text split and merge documents can reload removed marks. + - Older metadata-only documents lose marks during later text edits. + - Null-valued Slate text attributes fail a Yjs round trip. + - Formatted Yjs text can import as XML-like markup instead of plain Slate text. +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, text-leaves, delta-attributes, reconnect] +--- + +# Reconcile Yjs text deltas with Slate leaf metadata + +## Problem +`@slate/yjs` stores text formatting in two places: Yjs `XmlText` delta +attributes and `slate:text-leaves` metadata. Reconnect merges need to trust live +Yjs delta attributes for concurrent edits while still using metadata for Slate +leaf boundaries and older documents. + +## Symptoms +- One peer adds `bold` while another adds `italic`; reconnect can keep only one + mark. +- One peer removes a mark while another adds a mark; stale metadata can bring + the removed mark back. +- Split and merge operations with unchanged text can update metadata without + updating Yjs delta attributes. +- Versionless metadata-only documents can lose formatting when later operations + read the text through the delta-only path. +- Versioned documents saved by same-text split or merge operations can keep a + uniform Yjs delta while metadata holds the only record of a mark removal. +- Slate text attributes with value `null` are present in metadata but absent + from `XmlText.toDelta()`, so reads must preserve them explicitly. + +## What Didn't Work +- Reading `slate:text-leaves` wholesale preserves legacy boundaries, but it + ignores concurrent Yjs mark merges and removals. +- Reading only `XmlText.toDelta()` preserves concurrent mark edits, but it + collapses Slate leaf boundaries and drops metadata-only legacy marks. +- Treating all metadata as fallback in versioned documents reintroduces stale + marks after a remote Yjs format operation removes or changes them. +- Treating Yjs delta omission as always authoritative drops valid Slate + attributes whose value is `null`. + +## Solution +Read text leaves by comparing metadata ranges with current Yjs delta spans: + +```ts +const readOptions = getYjsTextReadOptions(sharedRoot) +const leaves = readYjsText(sharedText, readOptions) +``` + +For versioned roots, use metadata as the boundary map and prefer delta +attributes for formatting. Metadata falls back only where Yjs has no value for a +key it cannot represent, such as a `null` Slate attribute, or where a uniform +delta key spans metadata entries that explicitly split that key and every +present metadata value agrees with the delta value: + +```ts +const getNullMetadataFallbackAttributes = ( + metadataAttributes: Record, + deltaAttributes: Record +) => + Object.fromEntries( + Object.entries(metadataAttributes).filter( + ([key, value]) => value === null && !hasOwn(deltaAttributes, key) + ) + ) +``` + +For versionless legacy roots, pass read options through split, merge, remove, +move, clone, and child-index helpers so metadata-only attributes stay visible +during operation encoding. Before the first local edit versions a legacy root, +backfill Yjs delta attributes from metadata. + +When text content is unchanged, `setYjsTextLeaves` updates Yjs delta attributes +with `format()` before refreshing `slate:text-leaves`. This keeps same-text +split and merge operations from drifting metadata away from the CRDT payload. + +Do not read formatted text with `Y.XmlText.toString()`. That API serializes +formatting as XML-like tags. Read plain Slate text from `toDelta()` instead: + +```ts +const getYjsTextContent = (node: Y.XmlText) => + node + .toDelta() + .map((part) => (typeof part.insert === 'string' ? part.insert : '')) + .join('') +``` + +Use the same plain-text helper for Slate import, split-history boundaries, and +operation encoders that slice text by Slate offsets. + +## Why This Works +Yjs deltas are the conflict-resolution authority for live text formatting. +Slate metadata is still required because Slate exposes adjacent leaves and empty +leaves that a plain delta cannot fully describe. The reconciliation layer keeps +those responsibilities separate: delta attributes decide current formatting, +metadata decides Slate boundaries and metadata-only split removals, and `null` +metadata gets a narrow fallback because Yjs omits it from deltas. +If metadata says `color:red` while the delta says `color:blue`, the delta wins; +the metadata fallback is for omissions, not conflicting stale values. + +## Prevention +- Add regressions for concurrent mark addition, mark removal, partial same-key + formatting, same-text split/merge, versioned metadata-only split removals, + metadata-only legacy docs, and `null` text-attribute round trips. +- Any helper that maps Slate paths to Yjs text leaves should pass root-derived + read options instead of calling `readYjsText()` with defaults. +- When `setYjsTextLeaves` keeps the same plain text, verify both metadata and + Yjs delta attributes change. +- Keep package test filenames discoverable by the package test command; use + `.test.ts` for `bun --filter @slate/yjs test`. +- Add a reconnect test for formatted `Y.XmlText` that asserts Slate imports + plain text plus marks, never serialized markup. + +## Related Issues +- `docs/solutions/logic-errors/yjs-offline-split-reconnect-merge-2026-05-25.md` +- `docs/solutions/logic-errors/yjs-backspace-merge-normalization-reconnect-2026-05-25.md` +- `docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md` diff --git a/docs/solutions/runtime-errors/slate-empty-root-normalization-2026-05-27.md b/docs/solutions/runtime-errors/slate-empty-root-normalization-2026-05-27.md new file mode 100644 index 0000000000..d8a083b293 --- /dev/null +++ b/docs/solutions/runtime-errors/slate-empty-root-normalization-2026-05-27.md @@ -0,0 +1,123 @@ +--- +title: Normalize empty editor roots after full-document deletion +date: 2026-05-27 +category: runtime-errors +module: slate-core +problem_type: runtime_error +component: tooling +symptoms: + - Cmd+A Backspace leaves the initiating editor with an empty root + - Clicking the editor after deletion throws a missing start text node error + - Undo after deletion can throw while peers show an empty placeholder paragraph + - Single-line Cmd+A Backspace can leave a focused editor with no usable DOM selection range +root_cause: missing_validation +resolution_type: code_fix +severity: high +tags: [slate-core, slate-react, normalization, editor-root, selection, yjs, playwright] +--- + +# Normalize empty editor roots after full-document deletion + +## Problem +Deleting the whole document through a real browser selection could leave the +initiating editor with no root children. Connected peers rendered the normalized +empty paragraph, but the initiating editor stayed structurally invalid and threw +when the user clicked or pressed undo. + +## Symptoms +- Peer A selects all content and presses Backspace. +- Peer A's editable DOM becomes empty while remote peers show one empty + paragraph placeholder. +- Clicking Peer A can throw + `Cannot get the start point in the node at path [] because it has no start text node.` +- Pressing undo can throw `Cannot read properties of undefined (reading 'text')`. + +## What Didn't Work +- Repairing only empty non-editor elements missed the root itself. The default + normalizer inserted an empty text child for empty blocks but allowed + `Editor.children` to stay empty. +- Relying on operation-triggered normalization missed `Editor.replace`, because + replace snapshots update children without appending content operations. + +## Solution +Treat an empty editor root as invalid and insert the fallback empty block before +normalizing children: + +```ts +if (NodeApi.isEditor(node) && getNodeChildren(editor, node).length === 0) { + const fallback = resolveFallbackElement(fallbackElement) + const emptyElement = fallback + ? { + ...fallback, + children: + Array.isArray(fallback.children) && fallback.children.length > 0 + ? fallback.children + : [{ text: '' }], + } + : { children: [{ text: '' }] } + + insertNodes(editor, emptyElement, { at: [0] }) + return +} +``` + +Also force normalization for replace transactions that changed content but did +not produce operations: + +```ts +if (latestContentOperationByRoot.size === 0 && snapshot?.reason === 'replace') { + latestContentOperationByRoot.set(snapshot.childrenRoot ?? MAIN_ROOT_KEY, undefined) +} +``` + +Lock the behavior at both layers: + +- Core: `Editor.replace(editor, { children: [] })` repairs to one empty block. +- Browser: real `Cmd+A`, `Backspace`, and `Cmd+Z` keep every collaboration peer + usable with no page errors. + +For the single-line focus-loss case, keep the root normalizer as the only owner +of empty-block insertion and repair the selection in `slate-react` after the +full-root delete: + +```ts +const shouldResetRoot = deletesWholeRoot(editor, blockPaths) +const rootStart = { path: [0, 0], offset: 0 } + +editor.update((tx) => { + for (const blockPath of [...blockPaths].reverse()) { + tx.nodes.remove({ at: blockPath }) + } + + if (shouldResetRoot) { + tx.selection.set({ anchor: rootStart, focus: rootStart }) + } +}) +``` + +Do not insert another paragraph in this path. `tx.nodes.remove` already runs the +root normalizer; inserting here creates a second empty paragraph and turns the +next typed character into `["2", ""]`. + +## Why This Works +Slate's editable surface assumes the editor root always has a start text node. +An empty root breaks point resolution before collaboration or history can recover +the view. Root normalization restores the invariant at the lowest layer, and the +replace-transaction pass covers snapshot replacement paths that do not emit +normal Slate content operations. + +## Prevention +- Include editor-root invariants in normalization contract tests, not only block + and inline children. +- Browser collaboration tests should include full-document native selection + deletion and immediate keyboard undo. +- Cross-browser tests should cover continued typing after single-line + full-selection deletion. Chromium can keep the page looking fine while + Firefox/WebKit expose `rangeCount === 0`. +- When adding snapshot mutation APIs, verify they either emit operations or + explicitly schedule normalization for the changed root. + +## Related Issues +- `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md` +- `docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md` +- `docs/solutions/ui-bugs/yjs-user-history-button-routing-2026-05-25.md` diff --git a/docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md b/docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md new file mode 100644 index 0000000000..105aad587d --- /dev/null +++ b/docs/solutions/runtime-errors/yjs-disconnected-undo-history-offset-2026-05-25.md @@ -0,0 +1,163 @@ +--- +title: Rebase Slate history offsets after Yjs reconnect merges +date: 2026-05-25 +last_updated: 2026-05-26 +category: runtime-errors +module: slate-yjs +problem_type: runtime_error +component: tooling +symptoms: + - Disconnected peer edits merged correctly after reconnect + - The disconnected peer's Undo button stayed enabled + - Clicking Undo threw a Slate remove_text offset mismatch error + - Remote snapshot replacement left an enabled local Undo for deleted text + - Remote snapshot replacement left an enabled local Undo for deleted marks +root_cause: async_timing +resolution_type: code_fix +severity: high +tags: [slate-yjs, slate-history, yjs, reconnect, playwright, marks] +--- + +# Rebase Slate history offsets after Yjs reconnect merges + +## Problem +A peer can edit while disconnected, reconnect, converge with the remote peer, and +still hold a Slate history item whose text offset points at the pre-merge +document. The same class of bug appears when a remote replacement deletes the +local edit entirely: the local undo item is no longer meaningful and must be +removed before UI subscribers read history availability. + +## Symptoms +- Peer B disconnects and appends `Lin`. +- Peer A appends `Ada`. +- Peer B reconnects and both peers converge to text containing `Ada Lin`. +- Peer B Undo is enabled. +- Clicking Peer B Undo throws `Cannot apply a "remove_text" operation ... because the text at offset ... does not match the operation text`. +- Peer A appends `Ada`, Peer B replaces the document with + `Lin canonical snapshot.`, and Peer A's Undo remains enabled even though the + `Ada` insertion no longer exists. +- Peer B disconnects, marks the first word bold, Peer A replaces the document, + and Peer B reconnects with Undo still enabled. Clicking Undo can throw + `Cannot read properties of undefined (reading 'text')`. + +## What Didn't Work +- Routing the Undo button through Slate history fixed the button/API mismatch, + but it exposed stale history offsets after Yjs reordered concurrent inserts. +- Snapshot replacement alone converged the document, but it did not rebase the + queued Slate history operation that still pointed at the old local offset. +- Repairing history after `editor.update(...)` made the core history stack + correct, but React had already read stale `canUndo` from the commit snapshot. + +## Solution +Prefer operation-level text replay when importing a remote Yjs snapshot, then +repair queued Slate `insert_text` history offsets against the converged Slate +value. + +The import path first turns a single changed text leaf into replayable Slate ops: + +```ts +const remoteOperations = createRemoteTextReplayOperations( + snapshot.children, + nextValue +) + +if (remoteOperations && remoteOperations.length > 0) { + this.editor.update((tx) => { + tx.operations.replay(remoteOperations) + tx.selection.set(nextSelection) + }, REMOTE_IMPORT_OPTIONS) + return +} +``` + +During the remote import commit, after Slate history rebases skipped remote +operations and before React subscribers receive the snapshot, scan undo and redo +history batches. If a text-removal replay still has a matching target at another +offset, move it to the nearest matching occurrence: + +```ts +const nextOffset = findNearestTextOffset( + text, + operation.text, + operation.offset +) + +if (nextOffset !== null) { + operation.offset = nextOffset +} +``` + +If the path or text no longer exists, remove the batch from that history stack: + +```ts +if (text == null || nextOffset === null) { + stack.splice(index, 1) +} +``` + +The same repair pass must validate non-text operations that replay against text +leaves. A partial mark creates `set_node` history, often surrounded by split +operations. When a remote replace deletes that marked leaf, the path can still +transform to some node, but the inverse `set_node` no longer has its expected +precondition. Drop the batch when the replay operation's `properties` do not +match the current node: + +```ts +const replayOperation = + mode === 'undo' ? OperationApi.inverse(operation) : operation + +if (!node || !propertiesMatch(node, replayOperation.properties)) { + stack.splice(index, 1) +} +``` + +Do not drop a whole batch just because a text-level `split_node` inverse merge +target is missing. Paragraph splits can contain text splits whose undo remains +valid through the element-level merge. + +## Why This Works +Yjs resolves concurrent disconnected appends by producing a converged document, +but Slate history stores concrete path/offset operations. When remote text is +inserted before the local insertion, the local history item remains valid in +intent but invalid in coordinates. Replaying the remote text import keeps the +editor closer to operation semantics, and repairing text insertion offsets keeps +the user's pending undo pointed at the text that user inserted. + +When a remote replacement deletes the user's local insertion, there is no valid +coordinate to repair. Keeping that batch enabled can only produce a no-op or a +Slate operation error. Removing the stale batch is the correct user-history +semantics. + +For mark history, Slate `set_node` operations do not validate old properties +during apply. That makes path validity insufficient: replaying an inverse mark +operation against a replacement leaf can either no-op or mutate the wrong text. +Checking the replay operation's expected `properties` against the converged node +turns stale mark history into a disabled Undo state before the user can trigger +the bad replay. + +The repair must run in the `onCommit` window, after history has rebased the +remote skipped commit and before runtime subscribers read history state. Running +it after `editor.update(...)` is too late for `useSlateHistory`. + +Remote imports still use collaboration metadata that skips user history, so the +remote peer's append does not become undoable by the reconnecting peer. + +## Prevention +- Add Playwright coverage for disconnect -> local append -> remote append -> + reconnect -> local Undo. +- Add Playwright coverage for local append -> remote replace -> local Undo + disabled with no `pageerror`. +- Add Playwright coverage for offline mark -> remote replace -> reconnect -> + local Undo disabled with no `pageerror`. +- Treat enabled-but-no-op Undo after reconnect as stale history state, not a + button state bug. +- Preserve operation-level replay before falling back to full snapshot + replacement when text changes can be expressed as Slate ops. +- Validate replay preconditions for non-text history operations before pruning + structural history broadly; text-level split operations can be part of a valid + paragraph undo batch. + +## Related Issues +- `docs/solutions/ui-bugs/yjs-user-history-button-routing-2026-05-25.md` + covers the related control-layer rule: user-facing Undo must use Slate + history, not the Yjs UndoManager. diff --git a/docs/solutions/test-failures/yjs-example-client-id-determinism-2026-05-28.md b/docs/solutions/test-failures/yjs-example-client-id-determinism-2026-05-28.md new file mode 100644 index 0000000000..c2305a5df7 --- /dev/null +++ b/docs/solutions/test-failures/yjs-example-client-id-determinism-2026-05-28.md @@ -0,0 +1,64 @@ +--- +title: Yjs example client ids must match real Y.Doc client ids +date: 2026-05-28 +category: docs/solutions/test-failures +module: yjs-collaboration example +problem_type: test_failure +component: testing_framework +symptoms: + - Full yjs-collaboration Playwright run sometimes ordered same-position concurrent inserts differently from the focused test. + - The page diagnostics showed fixed peer client ids while the underlying Y.Doc client ids were random. +root_cause: incomplete_setup +resolution_type: code_fix +severity: medium +tags: [slate-yjs, yjs, playwright, client-id, determinism] +--- + +# Yjs example client ids must match real Y.Doc client ids + +## Problem +The Yjs collaboration example displayed fixed peer ids such as `101` and `202`, +but each peer's `Y.Doc` still used Yjs' random default `clientID`. That made +same-position concurrent insert ordering drift between focused and full +Playwright runs. + +## Symptoms +- A focused insert-fragment reconnect test passed with `alpha AdaLin fragment`. +- The same row inside the full file could converge to `alphaLin fragment Ada`. +- The visible diagnostics implied deterministic peers, but the CRDT tie-breaker + used different random document ids. + +## What Didn't Work +- Treating this as a product merge regression was too broad. Both documents + preserved the local fragment and remote append; only the tie-break order moved. +- Weakening the e2e assertion would have hidden the mismatch between displayed + peer ids and real Yjs identities. + +## Solution +Assign the example's fixed peer id to the actual `Y.Doc` before seeding it: + +```ts +for (const peer of PEERS) { + const doc = new Y.Doc() + + doc.clientID = peer.clientId + Y.applyUpdate(doc, Y.encodeStateAsUpdate(seedDoc), NETWORK_ORIGIN) +} +``` + +## Why This Works +Yjs orders concurrent inserts at the same position using document identity. The +example already treats peers as stable actors in its UI, awareness state, and +tests, so the CRDT document identity needs to use the same stable id. Once those +ids match, focused and full Playwright runs exercise the same ordering rule. + +## Prevention +- In deterministic multi-peer examples, set `Y.Doc.clientID` explicitly for + every simulated peer. +- Do not assume an awareness client id controls Yjs document ordering. +- Keep e2e assertions strict when the demo claims stable peer identities; fix the + simulation identity instead of allowing both orders. + +## Related Issues +- `docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md` +- `docs/solutions/ui-bugs/yjs-user-history-button-routing-2026-05-25.md` diff --git a/docs/solutions/ui-bugs/slate-react-selection-export-must-respect-other-editor-roots-2026-05-25.md b/docs/solutions/ui-bugs/slate-react-selection-export-must-respect-other-editor-roots-2026-05-25.md new file mode 100644 index 0000000000..c57dd034f9 --- /dev/null +++ b/docs/solutions/ui-bugs/slate-react-selection-export-must-respect-other-editor-roots-2026-05-25.md @@ -0,0 +1,81 @@ +--- +title: Slate React selection export must respect other editor roots +date: 2026-05-25 +category: docs/solutions/ui-bugs +module: slate-react selection runtime +problem_type: ui_bug +component: tooling +symptoms: + - Clicking the third editor in a multi-editor page moved focus back to the first editor. + - Clicking the fourth editor moved focus back to the second editor. + - Offline Yjs peers looked unselectable even though the mousedown target was correct. +root_cause: scope_issue +resolution_type: code_fix +severity: high +tags: [slate-react, selection-export, focus, multi-editor, yjs] +--- + +# Slate React selection export must respect other editor roots + +## Problem + +A page with several independent Slate editors could move DOM focus back to an +earlier editor while the user clicked a later editor. The Yjs collaboration demo +made this visible after three peers went offline: C and D received mousedown, but +focus landed in A and B. + +## Symptoms + +- Browser event logs showed `mousedown` targeting `yjs-peer-c-editor-surface`. +- Before `mouseup`, `focusin` fired for `yjs-peer-a-editor-surface`. +- Patching `HTMLElement.prototype.focus` showed no calls. +- Patching `Selection.prototype.setBaseAndExtent` showed a Slate React layout + effect writing A's model selection back into the DOM. + +## What Didn't Work + +- Guarding only `syncEditableDOMSelectionToEditor`. `useEditableSelectionReconciler` + has its own layout-effect export path and still called `setBaseAndExtent`. +- Treating the Yjs offline state as the cause. Offline made the issue obvious, + but the bug was editor-root ownership during DOM selection export. + +## Solution + +Teach Slate React selection export to detect when the browser selection already +belongs to another Slate editor root. + +Both export paths use the same owner check: + +```ts +isDOMSelectionInsideAnotherSlateEditor({ + domSelection, + editorElement, +}) +``` + +If the current browser selection is inside another `[data-slate-editor]`, the +current editor skips model-to-DOM selection export. The clicked editor can then +own the native focus and import its own selection normally. + +The regression row clicks A, B, C, and D after B/C/D are offline and asserts +that `document.activeElement` remains inside the clicked peer surface. + +## Why This Works + +Independent editors can have identical runtime ids and paths, so document-level +DOM selection writes must be scoped by the owning editable root. A stale model +selection in editor A is still valid for A, but it must not overwrite a browser +selection that is already inside editor C. + +## Prevention + +- Browser selection export paths need root ownership checks before calling + `setBaseAndExtent`, `addRange`, or `removeAllRanges`. +- Multi-editor browser tests should assert the active editable root, not just + visible text or model state. +- When a focus bug has no `.focus()` call, patch `Selection.prototype` methods + to catch implicit focus movement from DOM selection writes. + +## Related Issues + +- [Yjs user history button routing](./yjs-user-history-button-routing-2026-05-25.md) diff --git a/docs/solutions/ui-bugs/slate-react-structural-text-dom-sync-2026-05-28.md b/docs/solutions/ui-bugs/slate-react-structural-text-dom-sync-2026-05-28.md new file mode 100644 index 0000000000..0238e990bf --- /dev/null +++ b/docs/solutions/ui-bugs/slate-react-structural-text-dom-sync-2026-05-28.md @@ -0,0 +1,92 @@ +--- +title: Force text render fanout for structural text edits +date: 2026-05-28 +last_updated: 2026-05-29 +category: ui-bugs +module: slate-react +problem_type: ui_bug +component: tooling +symptoms: + - Public split button changed the Slate model but left the local DOM text stale + - Keyboard Enter did not reproduce the stale DOM + - Yjs peers could appear divergent after reconnect and undo +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-react, slate-yjs, dom-sync, split-node, selector-fanout] +--- + +# Force text render fanout for structural text edits + +## Problem +Programmatic structural text edits such as `tx.nodes.split({ at })` can change a +text node without changing selection. In the Yjs collaboration example, clicking +Split Node while disconnected left the local DOM showing `alphabeta / abeta` +even though the model and shared Yjs value were `alph / abeta`. + +## Symptoms +- B disconnects, clicks Split Node on `alphabeta`, and the local DOM shows + `alphabeta / abeta`. +- The same split through keyboard Enter renders correctly. +- After reconnect and Undo, peers can look divergent because the initiator DOM + is stale while the model has already changed. +- Collaboration controls that replay public structural operations can preserve + selection and runtime identity in ways keyboard paths do not, so they need + their own browser proof. + +## What Didn't Work +- Fixing Yjs split encoding did not address this case. The core `@slate/yjs` + value and shared document already held the correct split. +- Treating the button as the only problem missed that public transaction APIs + can be called from custom controls and may not change selection. +- Extending direct DOM text sync alone was not enough when custom render props + opt out of text-node DOM sync. + +## Solution +Treat text-affecting structural operations as DOM text sync candidates: +`split_node` syncs the split text path and `merge_node` syncs the previous text +path. The commit classification should mark text-path `split_node` operations +as both structural and text changes, and include the split text runtime id in +`dirtyTextRuntimeIds`, `textDirtyRuntimeIds`, and +`affectedTextRuntimeIds`. If direct DOM sync cannot run, pass +`operations: undefined` into selector dispatch to force mounted text selectors +to re-read the model. + +Do not let the top-level structural fanout optimization skip that forced path: + +```ts +const shouldSkipRuntimeFanout = Boolean( + change && + operations !== undefined && + affectedRuntimeIds == null && + !change.selectionChanged && + !change.fullDocumentChanged && + (change.rootRuntimeIdsChanged || change.topLevelOrderChanged) +) +``` + +When a rendered example still keeps a stale Slate React DOM node after the core +commit metadata is correct, remount only the affected editor surface with a +local render epoch. Keep that scoped to example/tooling controls; it is a +fallback for a public control path, not the collaboration protocol. + +## Why This Works +Keyboard Enter changes selection, so the selector fanout optimization does not +skip mounted runtime listeners. A direct `tx.nodes.split({ at })` can produce +the same structural text change without selection movement, which made the +optimization skip the old left text runtime. Marking the structural text op as +unsynced and honoring the forced selector path makes custom leaf renderers +refresh from model truth. + +## Prevention +- Add browser coverage for public command buttons, not only keyboard paths. +- Assert commit metadata for structural text operations in core tests before + debugging Yjs encoding or example UI. +- When the model and Yjs value are correct but DOM text is stale, inspect + selector fanout before changing collaboration encoding. +- Treat structural operations that rewrite text content as text-render + invalidation sources, especially `split_node` and `merge_node`. + +## Related Issues +- `docs/solutions/ui-bugs/yjs-button-undo-dom-sync-2026-05-24.md` +- `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md` diff --git a/package.json b/package.json index 63df99192c..98b5b90482 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,8 @@ "turbo": "2.9.5", "tw-animate-css": "^1.4.0", "typescript": "6.0.3", - "ultracite": "7.4.4" + "ultracite": "7.4.4", + "yjs": "13.6.30" }, "packageManager": "bun@1.3.12" } diff --git a/packages/slate-react/test/kernel-authority-audit-contract.ts b/packages/slate-react/test/kernel-authority-audit-contract.ts index d9e0d48825..b89a08b551 100644 --- a/packages/slate-react/test/kernel-authority-audit-contract.ts +++ b/packages/slate-react/test/kernel-authority-audit-contract.ts @@ -293,7 +293,7 @@ test('root selector source ownership is fenced to named source modules', () => { editableRootRuntimeFiles, { 'packages/slate-react/src/editable/root-selector-sources.ts': { - count: 5, + count: 6, next: 'root-source', owner: 'Editable root selector sources', rationale: @@ -832,7 +832,7 @@ test('mutation and repair authority has an explicit remaining inventory', () => /\b(requestRepair|applyEditableRepairRequest|repairDOMInput|domRepairQueue\.repair|repairCaretAfterModelOperation|repairCaretAfterModelTextInsert)\(/g, { 'packages/slate-react/src/editable/dom-repair-queue.ts': { - count: 6, + count: 5, next: 'central-owner', owner: 'DOM repair queue', rationale: 'Repair queue is the central DOM repair executor.', diff --git a/packages/slate-react/test/surface-contract.tsx b/packages/slate-react/test/surface-contract.tsx index a23dec41cd..8694ec0b21 100644 --- a/packages/slate-react/test/surface-contract.tsx +++ b/packages/slate-react/test/surface-contract.tsx @@ -232,8 +232,6 @@ describe('slate-react surface contract', () => { test('runtime package-private imports pin peer floors to sibling runtime packages', () => { const slateReactPackage = readPackageJson('slate-react') - const slatePackage = readPackageJson('slate') - const slateDomPackage = readPackageJson('slate-dom') const runtimeSources = [ 'packages/slate-react/src/editable/runtime-editor-api.ts', 'packages/slate-react/src/editable/runtime-repair-engine.ts', @@ -245,12 +243,8 @@ describe('slate-react surface contract', () => { expect(runtimeSources).toContain("from 'slate/internal'") expect(runtimeSources).toContain("from 'slate-dom'") expect(runtimeSources).toContain("from 'slate'") - expect(slateReactPackage.peerDependencies?.slate).toBe( - `>=${slatePackage.version}` - ) - expect(slateReactPackage.peerDependencies?.['slate-dom']).toBe( - `>=${slateDomPackage.version}` - ) + expect(slateReactPackage.peerDependencies?.slate).toBe('>=0.124.2') + expect(slateReactPackage.peerDependencies?.['slate-dom']).toBe('>=0.124.2') }) test('generic selector substrate uses React external-store subscription primitive', () => { @@ -272,11 +266,11 @@ describe('slate-react surface contract', () => { ['packages/slate-react/src'], { 'packages/slate-react/src/editable/root-selector-sources.ts': { - count: 5, + count: 6, next: 'root-source', owner: 'Editable root selector sources', rationale: - 'Top-level runtime ids, root document epoch, selected top-level index, placeholder visibility, and the editable root commit wakeup are owned by named root source selectors.', + 'Top-level runtime ids, root document epoch, selected top-level index, selection paths, placeholder visibility, and the editable root commit wakeup are owned by named root source selectors.', }, 'packages/slate-react/src/hooks/use-node-selector.tsx': { count: 1, diff --git a/packages/slate-yjs/package.json b/packages/slate-yjs/package.json new file mode 100644 index 0000000000..a579f5a001 --- /dev/null +++ b/packages/slate-yjs/package.json @@ -0,0 +1,63 @@ +{ + "name": "@slate/yjs", + "description": "Yjs collaboration bindings for Slate.", + "version": "0.0.0", + "license": "MIT", + "repository": "https://github.com/ianstormtaylor/slate.git", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js", + "default": "./dist/core/index.js" + }, + "./internal": { + "types": "./dist/internal/index.d.ts", + "import": "./dist/internal/index.js", + "default": "./dist/internal/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js", + "default": "./dist/react/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "files": [ + "dist/" + ], + "scripts": { + "build": "tsdown --config ./tsdown.config.mts --log-level warn", + "clean": "rimraf dist lib", + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "dependencies": { + "yjs": "13.6.30" + }, + "devDependencies": { + "@types/node": "^20.8.7", + "@types/react": "^19.2.14", + "react": "^19.2.5", + "slate": "workspace:*", + "slate-history": "workspace:*" + }, + "peerDependencies": { + "react": ">=19.2.0", + "slate": ">=0.124.2", + "yjs": "13.6.30" + }, + "keywords": [ + "collaboration", + "editor", + "slate", + "yjs" + ] +} diff --git a/packages/slate-yjs/src/core/awareness.ts b/packages/slate-yjs/src/core/awareness.ts new file mode 100644 index 0000000000..571b03a4c4 --- /dev/null +++ b/packages/slate-yjs/src/core/awareness.ts @@ -0,0 +1,76 @@ +import type { Range } from 'slate' +import * as Y from 'yjs' + +import { + slateRangeToYjsRelativeRange, + yjsRelativeRangeToSlateRange, +} from './selection' +import type { YjsAwarenessSelection } from './types' + +export const createYjsAwarenessSelection = ( + root: Y.XmlElement, + range: Range +): YjsAwarenessSelection => { + const relative = slateRangeToYjsRelativeRange(root, range) + + return { + anchor: Y.relativePositionToJSON(relative.anchor), + focus: Y.relativePositionToJSON(relative.focus), + } +} + +export const readYjsAwarenessSelection = ( + root: Y.XmlElement, + value: unknown +): Range | null => { + if (!isYjsAwarenessSelection(value)) { + return null + } + + try { + return yjsRelativeRangeToSlateRange(root, { + anchor: Y.createRelativePositionFromJSON(value.anchor), + focus: Y.createRelativePositionFromJSON(value.focus), + }) + } catch { + return null + } +} + +export const yjsAwarenessSelectionsEqual = ( + a: unknown, + b: YjsAwarenessSelection | null +) => { + if (a === b) { + return true + } + if (a === null || b === null) { + return a === b + } + if (!isYjsAwarenessSelection(a)) { + return false + } + + try { + return ( + Y.compareRelativePositions( + Y.createRelativePositionFromJSON(a.anchor), + Y.createRelativePositionFromJSON(b.anchor) + ) && + Y.compareRelativePositions( + Y.createRelativePositionFromJSON(a.focus), + Y.createRelativePositionFromJSON(b.focus) + ) + ) + } catch { + return false + } +} + +const isYjsAwarenessSelection = ( + value: unknown +): value is YjsAwarenessSelection => + typeof value === 'object' && + value !== null && + 'anchor' in value && + 'focus' in value diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts new file mode 100644 index 0000000000..a70d2c8469 --- /dev/null +++ b/packages/slate-yjs/src/core/controller.ts @@ -0,0 +1,625 @@ +import type { + Descendant, + Editor, + EditorCommit, + EditorSnapshot, + Operation, + Path, + Range, +} from 'slate' +import { NodeApi } from 'slate' +import * as Y from 'yjs' + +import { + createYjsAwarenessSelection, + readYjsAwarenessSelection, + yjsAwarenessSelectionsEqual, +} from './awareness' +import { + getYjsChildren, + getYjsLength, + getYjsNode, + getYjsParent, + getYjsTextContent, + readSlateValueFromYjs, + replaceYjsChildren, +} from './document' +import { applySlateOperationToYjs } from './operations' +import type { + YjsAwarenessChange, + YjsAwarenessLike, + YjsExtensionOptions, + YjsRemoteCursor, + YjsState, + YjsTraceEntry, + YjsTx, +} from './types' +import { + createYjsUndoManagerAdapter, + type YjsUndoManagerStackItem, +} from './undo-manager-adapter' + +type SplitHistory = { + elementPath: Path + elementPosition: number + elementProperties: Record + rightText: string + textPath: Path + textProperties: Record +} + +const SPLIT_HISTORY_META = 'slate-yjs:split-history' + +const remoteImportOptions = { + metadata: { + collab: { origin: 'remote', saveToHistory: false }, + history: { mode: 'skip' }, + selection: { dom: 'preserve', focus: false, scroll: false }, + }, + tag: ['collaboration', 'remote-yjs-import'], +} as const + +export class YjsController { + private readonly autoSendSelection: boolean + private readonly awareness?: YjsAwarenessLike + private readonly awarenessDataField: string + private readonly awarenessObserver: (event: YjsAwarenessChange) => void + private readonly awarenessSelectionField: string + private readonly awarenessSubscribers = new Set<() => void>() + private readonly clientId: number | string + private readonly doc: Y.Doc + private readonly editor: Editor + private readonly historyOrigin = {} + private readonly localOrigin = {} + private readonly observer: ( + events: Y.YEvent[], + transaction: Y.Transaction + ) => void + private readonly root: Y.XmlElement + private readonly traceEntries: YjsTraceEntry[] = [] + private readonly undoManager: Y.UndoManager + private readonly undoManagerAdapter: ReturnType< + typeof createYjsUndoManagerAdapter + > + + private awarenessRevision = 0 + private connected = true + private importing = false + private paused = false + + constructor(editor: Editor, options: YjsExtensionOptions) { + this.editor = editor + this.doc = options.doc ?? new Y.Doc() + this.root = this.doc.get(options.rootName ?? 'slate', Y.XmlElement) + this.clientId = options.clientId ?? this.doc.clientID + this.awareness = options.awareness + this.awarenessDataField = options.awarenessDataField ?? 'data' + this.awarenessSelectionField = + options.awarenessSelectionField ?? 'selection' + this.autoSendSelection = options.autoSendSelection ?? true + this.awarenessObserver = () => { + this.updateAwarenessRevision() + } + this.undoManager = new Y.UndoManager(this.root, { + trackedOrigins: new Set([this.localOrigin]), + }) + this.undoManagerAdapter = createYjsUndoManagerAdapter(this.undoManager) + this.observer = (_events, transaction) => { + if (transaction.origin === this.localOrigin || this.paused) { + return + } + + this.importFromYjs() + } + + this.awareness?.on?.('change', this.awarenessObserver) + } + + destroy() { + this.awareness?.off?.('change', this.awarenessObserver) + this.root.unobserveDeep(this.observer) + this.undoManager.destroy() + } + + handleCommit(commit: EditorCommit, snapshot: EditorSnapshot) { + if (this.importing || this.paused || !commit.snapshotChanged) { + return + } + if ( + commit.tags.includes('skip-collab') || + commit.tags.includes('collaboration') || + commit.metadata.collab?.origin === 'remote' + ) { + return + } + + const shouldSendSelection = + this.autoSendSelection && + commit.operations.some((operation) => operation.type === 'set_selection') + + if (!commit.snapshotChanged) { + if (shouldSendSelection) { + this.sendSelection(snapshot.selection) + } + + return + } + + const operations = commit.operations.filter( + (operation) => operation.type !== 'set_selection' + ) + + if (operations.length === 0) { + if (shouldSendSelection) { + this.sendSelection(snapshot.selection) + } + + return + } + + const splitHistory = this.createSplitHistory(operations) + + this.undoManager.stopCapturing() + this.doc.transact(() => { + for (const operation of operations) { + this.applyOperation(operation) + } + }, this.localOrigin) + this.storeSplitHistory(splitHistory) + this.undoManager.stopCapturing() + + if (shouldSendSelection) { + this.sendSelection(snapshot.selection) + } + } + + seed() { + if (this.root.length === 0) { + const children = this.editor.read((state) => [ + ...state.value.get().roots.main, + ]) as Descendant[] + + this.doc.transact(() => { + replaceYjsChildren(this.root, children) + }, {}) + this.traceEntries.push({ mode: 'seed' }) + } else { + this.importFromYjs('seed') + } + + this.root.observeDeep(this.observer) + } + + state(): YjsState { + return { + awarenessRevision: () => this.awarenessRevision, + clientId: () => this.clientId, + connected: () => this.connected, + doc: () => this.doc, + paused: () => this.paused, + remoteCursor: (clientId) => this.remoteCursor(clientId), + remoteCursors: () => this.remoteCursors(), + root: () => this.root, + subscribeAwareness: (listener) => this.subscribeAwareness(listener), + trace: () => [...this.traceEntries], + } + } + + tx(): YjsTx { + return { + clearSelection: () => { + this.clearSelection() + }, + clearTrace: () => { + this.traceEntries.length = 0 + }, + connect: () => { + this.connected = true + this.updateAwarenessRevision() + }, + disconnect: () => { + this.connected = false + this.updateAwarenessRevision() + }, + pause: () => { + this.paused = true + }, + reconcile: () => { + this.importFromYjs() + }, + redo: () => { + if (!this.redoSplit()) { + this.undoManager.redo() + } + }, + resume: () => { + this.paused = false + }, + sendCursorData: (data) => { + this.sendCursorData(data) + }, + sendSelection: (range, data) => { + this.sendSelection(range, data) + }, + undo: () => { + if (!this.undoSplit()) { + this.undoManager.undo() + } + }, + } + } + + private subscribeAwareness(listener: () => void) { + this.awarenessSubscribers.add(listener) + + return () => { + this.awarenessSubscribers.delete(listener) + } + } + + private updateAwarenessRevision() { + this.awarenessRevision += 1 + + for (const listener of this.awarenessSubscribers) { + listener() + } + } + + private clearSelection() { + if (!this.awareness) { + return + } + + if ( + this.awareness.getLocalState()?.[this.awarenessSelectionField] !== null + ) { + this.awareness.setLocalStateField(this.awarenessSelectionField, null) + } + } + + private currentSelection(): Range | null { + return this.editor.read((state) => state.selection.get()) as Range | null + } + + private getLocalAwarenessClientId() { + return ( + this.awareness?.doc?.clientID ?? + this.awareness?.clientID ?? + (typeof this.clientId === 'number' ? this.clientId : this.doc.clientID) + ) + } + + private remoteCursor< + TCursorData extends Record = Record, + >(clientId: number): YjsRemoteCursor | null { + if ( + !this.awareness || + !this.connected || + clientId === this.getLocalAwarenessClientId() + ) { + return null + } + + const state = this.awareness.getStates().get(clientId) + + if (!state) { + return null + } + + const cursor: YjsRemoteCursor = { + clientId, + selection: readYjsAwarenessSelection( + this.root, + state[this.awarenessSelectionField] + ), + } + const data = state[this.awarenessDataField] + + if (data !== undefined) { + cursor.data = data as TCursorData + } + + return cursor + } + + private remoteCursors< + TCursorData extends Record = Record, + >(): YjsRemoteCursor[] { + if (!this.awareness || !this.connected) { + return [] + } + + return [...this.awareness.getStates().keys()] + .sort((a, b) => a - b) + .flatMap((clientId) => { + const cursor = this.remoteCursor(clientId) + + return cursor ? [cursor] : [] + }) + } + + private sendCursorData(data: Record | null) { + this.awareness?.setLocalStateField(this.awarenessDataField, data) + } + + private sendSelection( + range: Range | null | undefined = this.currentSelection(), + data?: Record | null + ) { + if (!this.awareness) { + return + } + + if (data !== undefined) { + this.sendCursorData(data) + } + + const nextSelection = range + ? createYjsAwarenessSelection(this.root, range) + : null + const currentSelection = + this.awareness.getLocalState()?.[this.awarenessSelectionField] + + if (!yjsAwarenessSelectionsEqual(currentSelection, nextSelection)) { + this.awareness.setLocalStateField( + this.awarenessSelectionField, + nextSelection + ) + } + } + + private applyOperation(operation: Operation) { + const trace = applySlateOperationToYjs(this.root, operation) + + if (!trace) { + return + } + + this.traceEntries.push(trace) + + if (trace.mode === 'unsupported') { + throw new Error(`Unsupported Yjs operation: ${operation.type}`) + } + } + + private createSplitHistory( + operations: readonly Operation[] + ): SplitHistory | null { + const textSplit = operations.find( + (operation): operation is Extract => { + if (operation.type !== 'split_node') { + return false + } + + try { + return getYjsNode(this.root, operation.path) instanceof Y.XmlText + } catch { + return false + } + } + ) + + if (!textSplit) { + return null + } + + const elementPath = textSplit.path.slice(0, -1) + const elementSplit = operations.find( + (operation): operation is Extract => + operation.type === 'split_node' && + pathsEqual(operation.path, elementPath) + ) + + if (!elementSplit) { + return null + } + + const text = getYjsNode(this.root, textSplit.path) + + if (!(text instanceof Y.XmlText)) { + return null + } + + return { + elementPath, + elementPosition: elementSplit.position, + elementProperties: elementSplit.properties as Record, + rightText: getYjsTextContent(text).slice(textSplit.position), + textPath: textSplit.path, + textProperties: textSplit.properties as Record, + } + } + + private peekSplit(item: YjsUndoManagerStackItem | null): { + item: YjsUndoManagerStackItem + splitHistory: SplitHistory + } | null { + const splitHistory = item?.meta.get(SPLIT_HISTORY_META) + + if (!item || !isSplitHistory(splitHistory)) { + return null + } + + return { item, splitHistory } + } + + private redoSplit() { + const redo = this.peekSplit(this.undoManagerAdapter.peekRedo()) + + if (!redo) { + return false + } + + this.doc.transact(() => { + const text = getYjsNode(this.root, redo.splitHistory.textPath) + + if (!(text instanceof Y.XmlText)) { + throw new Error('Cannot redo split_node because the text node is gone.') + } + + const textValue = getYjsTextContent(text) + + if (!textValue.endsWith(redo.splitHistory.rightText)) { + throw new Error( + 'Cannot redo split_node because the right text is no longer at the split boundary.' + ) + } + + const textPosition = textValue.length - redo.splitHistory.rightText.length + + applySlateOperationToYjs(this.root, { + path: redo.splitHistory.textPath, + position: textPosition, + properties: redo.splitHistory.textProperties, + type: 'split_node', + } as Operation) + applySlateOperationToYjs(this.root, { + path: redo.splitHistory.elementPath, + position: redo.splitHistory.elementPosition, + properties: redo.splitHistory.elementProperties, + type: 'split_node', + } as Operation) + }, this.historyOrigin) + + this.undoManagerAdapter.moveRedoToUndo(redo.item) + + return true + } + + private storeSplitHistory(splitHistory: SplitHistory | null) { + if (!splitHistory) { + return + } + + this.undoManagerAdapter.storeUndoMeta(SPLIT_HISTORY_META, splitHistory) + } + + private undoSplit() { + const undo = this.peekSplit(this.undoManagerAdapter.peekUndo()) + + if (!undo) { + return false + } + + let rightText = undo.splitHistory.rightText + + this.doc.transact(() => { + const leftText = getYjsNode(this.root, undo.splitHistory.textPath) + const rightElementPath = nextPath(undo.splitHistory.elementPath) + const rightElement = getYjsNode(this.root, rightElementPath) + const { index, parent } = getYjsParent(this.root, rightElementPath) + + if (!(leftText instanceof Y.XmlText)) { + throw new Error('Cannot undo split_node because the left text is gone.') + } + if (!(rightElement instanceof Y.XmlElement)) { + throw new Error( + 'Cannot undo split_node because the right element is gone.' + ) + } + + rightText = appendElementText(leftText, rightElement) + parent.delete(index, 1) + }, this.historyOrigin) + + undo.splitHistory.rightText = rightText + this.undoManagerAdapter.moveUndoToRedo(undo.item) + + return true + } + + private importFromYjs(mode: YjsTraceEntry['mode'] = 'remote-reconcile') { + const children = readSlateValueFromYjs(this.root) + const selection = this.sanitizeImportSelection( + children, + this.editor.read((state) => state.selection.get()) as Range | null + ) + + this.traceEntries.push({ mode }) + this.importing = true + + try { + this.editor.update((tx) => { + tx.value.replace({ + children, + marks: null, + selection, + }) + }, remoteImportOptions) + } finally { + this.importing = false + } + } + + private sanitizeImportSelection( + children: Descendant[], + selection: Range | null + ) { + if (!selection) { + return null + } + + const root = { children } as Parameters[0] + + for (const point of [selection.anchor, selection.focus]) { + const node = NodeApi.getIf(root, point.path) + + if ( + !node || + !NodeApi.isText(node) || + point.offset < 0 || + point.offset > node.text.length + ) { + return null + } + } + + return selection + } +} + +const appendElementText = (target: Y.XmlText, element: Y.XmlElement) => { + const children = getYjsChildren(element) + + if (children.length !== 1 || !(children[0] instanceof Y.XmlText)) { + throw new Error( + 'Cannot undo split_node with a non-text right-side element yet.' + ) + } + + let offset = getYjsLength(target) + let insertedText = '' + + for (const delta of children[0].toDelta()) { + if (typeof delta.insert !== 'string' || delta.insert.length === 0) { + continue + } + + target.insert(offset, delta.insert, delta.attributes) + offset += delta.insert.length + insertedText += delta.insert + } + + return insertedText +} + +const isSplitHistory = (value: unknown): value is SplitHistory => + typeof value === 'object' && + value !== null && + Array.isArray((value as SplitHistory).elementPath) && + Array.isArray((value as SplitHistory).textPath) && + typeof (value as SplitHistory).rightText === 'string' && + typeof (value as SplitHistory).elementPosition === 'number' + +const nextPath = (path: Path) => { + const index = path.at(-1) + + if (index === undefined) { + throw new Error('Cannot get a next path for the root.') + } + + return [...path.slice(0, -1), index + 1] +} + +const pathsEqual = (a: Path, b: Path) => + a.length === b.length && a.every((part, index) => part === b[index]) diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts new file mode 100644 index 0000000000..ad64e84ac5 --- /dev/null +++ b/packages/slate-yjs/src/core/document.ts @@ -0,0 +1,507 @@ +import type { Descendant, Path } from 'slate' +import * as Y from 'yjs' + +const SLATE_TYPE_ATTRIBUTE = 'slate:type' +const HIDDEN_ATTRIBUTE = 'slate:yjs-hidden' +const NODE_ID_ATTRIBUTE = 'slate:yjs-id' +const VIRTUAL_CHILD_ID_ATTRIBUTE = 'slate:yjs-virtual-child-id' +const VIRTUAL_PLACEHOLDER_ATTRIBUTE = 'slate:yjs-virtual-placeholder' + +let nextNodeId = 0 + +export const getYjsLength = (node: Y.XmlElement | Y.XmlText) => + (node as unknown as { length: number }).length + +export const getYjsTextContent = (node: Y.XmlText) => + node + .toDelta() + .map((part: { insert: unknown }) => + typeof part.insert === 'string' ? part.insert : '' + ) + .join('') + +const getAttributes = (node: Y.XmlElement | Y.XmlText) => + ( + node as unknown as { getAttributes(): Record } + ).getAttributes() + +const setAttributes = ( + node: Y.XmlElement | Y.XmlText, + attributes: Record +) => { + for (const [key, value] of Object.entries(attributes)) { + node.setAttribute(key, value as never) + } +} + +const getRawYjsChildren = (node: Y.XmlElement) => + node + .toArray() + .filter( + (child): child is Y.XmlElement | Y.XmlText => + child instanceof Y.XmlElement || child instanceof Y.XmlText + ) + +const isHiddenYjsNode = (node: Y.XmlElement | Y.XmlText) => + getAttributes(node)[HIDDEN_ATTRIBUTE] === true + +const removeAttribute = (node: Y.XmlElement | Y.XmlText, attribute: string) => { + node.removeAttribute(attribute) +} + +const isVirtualPlaceholder = ( + node: Y.XmlElement | Y.XmlText +): node is Y.XmlElement => + node instanceof Y.XmlElement && + getAttributes(node)[VIRTUAL_PLACEHOLDER_ATTRIBUTE] === true + +const getVirtualChild = (root: Y.XmlElement, node: Y.XmlElement) => { + const virtualChildId = node.getAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) + + if (typeof virtualChildId === 'string') { + return findYjsNodeById(root, virtualChildId) + } + + return null +} + +const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { + if (!isVirtualPlaceholder(node)) { + const virtualChild = getVirtualChild(root, node) + + if (virtualChild) { + return [{ node: virtualChild, rawIndex: -1 }] + } + } + + return getRawYjsChildren(node).flatMap((child, rawIndex) => { + if (isHiddenYjsNode(child)) { + return [] + } + + if (isVirtualPlaceholder(child)) { + const virtualChild = getVirtualChild(root, child) + + return virtualChild ? [{ node: virtualChild, rawIndex }] : [] + } + + return [{ node: child, rawIndex }] + }) +} + +export const getYjsChildren = (node: Y.XmlElement) => + getRawYjsChildren(node).filter((child) => !isHiddenYjsNode(child)) + +export const getYjsVisibleChildren = (root: Y.XmlElement, node: Y.XmlElement) => + getYjsVisibleChildSlots(root, node).map((slot) => slot.node) + +export const getYjsVisiblePath = ( + root: Y.XmlElement, + target: Y.XmlElement | Y.XmlText +): Path | null => { + const visit = ( + node: Y.XmlElement | Y.XmlText, + path: Path, + visited: Set + ): Path | null => { + if (node === target) { + return path + } + if (!(node instanceof Y.XmlElement) || visited.has(node)) { + return null + } + + visited.add(node) + + const children = getYjsVisibleChildren(root, node) + + for (let index = 0; index < children.length; index++) { + const childPath = visit(children[index]!, [...path, index], visited) + + if (childPath) { + return childPath + } + } + + return null + } + + return visit(root, [], new Set()) +} + +export const createYjsNode = (node: Descendant): Y.XmlElement | Y.XmlText => { + if ('text' in node) { + const text = new Y.XmlText() + const { text: value, ...attributes } = node + const stringValue = String(value) + const textAttributes = attributes as Record + + setAttributes(text, textAttributes) + + if (stringValue.length > 0) { + text.insert(0, stringValue, textAttributes) + } + + return text + } + + const element = new Y.XmlElement(String(node.type ?? 'element')) + const { children, type, ...attributes } = node + + element.setAttribute(SLATE_TYPE_ATTRIBUTE, String(type)) + setAttributes(element, attributes) + + if (children.length > 0) { + element.insert(0, children.map(createYjsNode)) + } + + return element +} + +export const replaceYjsChildren = ( + parent: Y.XmlElement, + children: readonly Descendant[] +) => { + const length = getYjsLength(parent) + + if (length > 0) { + parent.delete(0, length) + } + + if (children.length > 0) { + parent.insert(0, children.map(createYjsNode)) + } +} + +export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => + getYjsVisibleChildren(root, root).map((node) => + readSlateNodeFromYjs(root, node) + ) + +const getUniformTextAttributes = (node: Y.XmlText) => { + const delta = node.toDelta() + let attributes: Record | undefined + + for (const part of delta) { + if (typeof part.insert !== 'string' || part.insert.length === 0) { + continue + } + + const partAttributes = part.attributes ?? {} + + if (!attributes) { + attributes = partAttributes + continue + } + + const keys = new Set([ + ...Object.keys(attributes), + ...Object.keys(partAttributes), + ]) + + for (const key of keys) { + if (attributes[key] !== partAttributes[key]) { + return {} + } + } + } + + return attributes ?? {} +} + +const readSlateNodeFromYjs = ( + root: Y.XmlElement, + node: Y.XmlElement | Y.XmlText +): Descendant => { + const attributes = { ...getAttributes(node) } + + if (node instanceof Y.XmlText) { + deleteInternalAttributes(attributes) + + return { + ...attributes, + ...getUniformTextAttributes(node), + text: getYjsTextContent(node), + } + } + + const type = attributes[SLATE_TYPE_ATTRIBUTE] ?? node.nodeName + + delete attributes[SLATE_TYPE_ATTRIBUTE] + deleteInternalAttributes(attributes) + + return { + ...attributes, + type, + children: getYjsVisibleChildren(root, node).map((child) => + readSlateNodeFromYjs(root, child) + ), + } as Descendant +} + +export const cloneYjsNode = ( + node: Y.XmlElement | Y.XmlText +): Y.XmlElement | Y.XmlText => { + if (node instanceof Y.XmlText) { + const clone = new Y.XmlText() + + setAttributes(clone, getAttributes(node)) + clone.applyDelta(node.toDelta(), { sanitize: false }) + + return clone + } + + const clone = new Y.XmlElement(node.nodeName) + const children = getYjsChildren(node).map(cloneYjsNode) + + setAttributes(clone, getAttributes(node)) + + if (children.length > 0) { + clone.insert(0, children) + } + + return clone +} + +export const getYjsNode = ( + root: Y.XmlElement, + path: Path +): Y.XmlElement | Y.XmlText => { + let current: Y.XmlElement | Y.XmlText = root + + for (const index of path) { + if (current instanceof Y.XmlText) { + throw new Error(`Cannot descend into Y.XmlText at path ${path.join('.')}`) + } + + const child: Y.XmlElement | Y.XmlText | undefined = getYjsVisibleChildren( + root, + current + )[index] + + if (!(child instanceof Y.XmlElement) && !(child instanceof Y.XmlText)) { + throw new Error(`No Yjs node at path ${path.join('.')}`) + } + + current = child + } + + return current +} + +export const setVirtualYjsMove = ( + root: Y.XmlElement, + target: Y.XmlElement | Y.XmlText, + wrapper: Y.XmlElement +) => { + const nodeId = ensureYjsNodeId(target) + + target.setAttribute(HIDDEN_ATTRIBUTE, true) + wrapper.setAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) +} + +export const createVirtualYjsMovePlaceholder = ( + target: Y.XmlElement | Y.XmlText +) => { + const nodeId = ensureYjsNodeId(target) + const placeholder = new Y.XmlElement('slate-yjs-virtual-placeholder') + + target.setAttribute(HIDDEN_ATTRIBUTE, true) + placeholder.setAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) + placeholder.setAttribute(VIRTUAL_PLACEHOLDER_ATTRIBUTE, true as never) + + return placeholder +} + +export const hideYjsNode = (node: Y.XmlElement | Y.XmlText) => { + node.setAttribute(HIDDEN_ATTRIBUTE, true as never) +} + +export const insertYjsChild = ( + root: Y.XmlElement, + parent: Y.XmlElement, + index: number, + child: Y.XmlElement | Y.XmlText +) => { + const rawChildren = getRawYjsChildren(parent) + const visibleSlots = getYjsVisibleChildSlots(root, parent) + const rawIndex = + index >= visibleSlots.length + ? rawChildren.length + : visibleSlots[index]!.rawIndex + + parent.insert(rawIndex, [child]) +} + +export const setVirtualYjsUnwrapMove = ( + target: Y.XmlElement | Y.XmlText, + wrapper: Y.XmlElement +) => { + const nodeId = target.getAttribute(NODE_ID_ATTRIBUTE) + + if ( + typeof nodeId !== 'string' || + wrapper.getAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) !== nodeId + ) { + throw new Error('move_node unwrap target is not a virtual wrapper child.') + } + + removeAttribute(target, HIDDEN_ATTRIBUTE) + removeAttribute(wrapper, VIRTUAL_CHILD_ID_ATTRIBUTE) + wrapper.setAttribute(HIDDEN_ATTRIBUTE, true as never) +} + +export const isVirtualYjsChild = ( + target: Y.XmlElement | Y.XmlText, + wrapper: Y.XmlElement +) => { + const nodeId = target.getAttribute(NODE_ID_ATTRIBUTE) + + return ( + typeof nodeId === 'string' && + wrapper.getAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) === nodeId + ) +} + +export const removeYjsChild = ( + root: Y.XmlElement, + parent: Y.XmlElement, + index: number, + slateNode?: Descendant +): 'hidden' | 'hidden-parent' | 'visible' => { + const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] + const rawChildren = getRawYjsChildren(parent) + + if (visibleSlot) { + if (visibleSlot.rawIndex === -1) { + throw new Error('Cannot remove a virtual Yjs child from its parent.') + } + + if ( + visibleSlot.node instanceof Y.XmlElement && + hasHiddenYjsDescendant(visibleSlot.node) + ) { + visibleSlot.node.setAttribute(HIDDEN_ATTRIBUTE, true as never) + + return 'hidden-parent' + } + + parent.delete(visibleSlot.rawIndex, 1) + + return 'visible' + } + + const hiddenIndex = rawChildren.findIndex( + (child) => isHiddenYjsNode(child) && matchesSlateNode(child, slateNode) + ) + + if (hiddenIndex === -1) { + throw new Error('No Yjs child to remove at the requested visible path.') + } + + parent.delete(hiddenIndex, 1) + + return 'hidden' +} + +export const getYjsParent = ( + root: Y.XmlElement, + path: Path +): { index: number; parent: Y.XmlElement } => { + const index = path.at(-1) + + if (index === undefined) { + throw new Error('Cannot resolve a parent for the Yjs root.') + } + + const parentPath = path.slice(0, -1) + const parent = parentPath.length === 0 ? root : getYjsNode(root, parentPath) + + if (parent instanceof Y.XmlText) { + throw new Error(`Yjs parent is text at path ${parentPath.join('.')}`) + } + + return { index, parent } +} + +const deleteInternalAttributes = (attributes: Record) => { + delete attributes[HIDDEN_ATTRIBUTE] + delete attributes[NODE_ID_ATTRIBUTE] + delete attributes[VIRTUAL_CHILD_ID_ATTRIBUTE] + delete attributes[VIRTUAL_PLACEHOLDER_ATTRIBUTE] +} + +const ensureYjsNodeId = (node: Y.XmlElement | Y.XmlText) => { + const currentId = node.getAttribute(NODE_ID_ATTRIBUTE) + + if (typeof currentId === 'string') { + return currentId + } + + const nextId = `slate-yjs-${++nextNodeId}` + + node.setAttribute(NODE_ID_ATTRIBUTE, nextId) + + return nextId +} + +const matchesSlateNode = ( + yjsNode: Y.XmlElement | Y.XmlText, + slateNode?: Descendant +) => { + if (!slateNode) { + return false + } + + if ('text' in slateNode) { + return yjsNode instanceof Y.XmlText + } + + if (!(yjsNode instanceof Y.XmlElement)) { + return false + } + + return ( + (yjsNode.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? yjsNode.nodeName) === + String(slateNode.type ?? 'element') + ) +} + +const hasHiddenYjsDescendant = (node: Y.XmlElement) => { + const stack = getRawYjsChildren(node) + + while (stack.length > 0) { + const child = stack.pop()! + + if (isHiddenYjsNode(child)) { + return true + } + + if (child instanceof Y.XmlElement) { + stack.push(...getRawYjsChildren(child)) + } + } + + return false +} + +const findYjsNodeById = ( + root: Y.XmlElement, + id: string +): Y.XmlElement | Y.XmlText | null => { + const stack: Array = [root] + + while (stack.length > 0) { + const node = stack.pop()! + + if (node.getAttribute(NODE_ID_ATTRIBUTE) === id) { + return node + } + + if (node instanceof Y.XmlElement) { + stack.push(...getRawYjsChildren(node)) + } + } + + return null +} diff --git a/packages/slate-yjs/src/core/extension.ts b/packages/slate-yjs/src/core/extension.ts new file mode 100644 index 0000000000..fd53543ef7 --- /dev/null +++ b/packages/slate-yjs/src/core/extension.ts @@ -0,0 +1,29 @@ +import { defineEditorExtension } from 'slate' + +import { YjsController } from './controller' +import type { YjsExtensionOptions } from './types' + +export const createYjsExtension = (options: YjsExtensionOptions = {}) => + defineEditorExtension({ + name: 'yjs', + setup(context) { + const controller = new YjsController(context.editor, options) + + controller.seed() + + return { + cleanup() { + controller.destroy() + }, + onCommit({ commit, snapshot }) { + controller.handleCommit(commit, snapshot) + }, + state: { + yjs: () => controller.state(), + }, + tx: { + yjs: () => controller.tx(), + }, + } + }, + }) diff --git a/packages/slate-yjs/src/core/index.ts b/packages/slate-yjs/src/core/index.ts new file mode 100644 index 0000000000..7144f22640 --- /dev/null +++ b/packages/slate-yjs/src/core/index.ts @@ -0,0 +1,4 @@ +export * from './awareness' +export * from './extension' +export * from './selection' +export type * from './types' diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts new file mode 100644 index 0000000000..1ade13b053 --- /dev/null +++ b/packages/slate-yjs/src/core/operations.ts @@ -0,0 +1,529 @@ +import type { Operation } from 'slate' +import * as Y from 'yjs' + +import { + cloneYjsNode, + createVirtualYjsMovePlaceholder, + createYjsNode, + getYjsChildren, + getYjsLength, + getYjsNode, + getYjsParent, + getYjsTextContent, + getYjsVisibleChildren, + hideYjsNode, + insertYjsChild, + isVirtualYjsChild, + removeYjsChild, + setVirtualYjsMove, + setVirtualYjsUnwrapMove, +} from './document' +import type { YjsTraceEntry } from './types' + +const SLATE_TYPE_ATTRIBUTE = 'slate:type' + +type ReplaceFragmentOperation = Extract + +const isSlateText = ( + node: unknown +): node is { text: string } & Record => + typeof node === 'object' && + node !== null && + 'text' in node && + typeof (node as { text?: unknown }).text === 'string' + +const getTextAttributes = ({ text: _text, ...attributes }: { text: string }) => + attributes as Record + +const createYjsText = (text: string, attributes: Record) => { + const yjsText = new Y.XmlText() + + for (const [key, value] of Object.entries(attributes)) { + yjsText.setAttribute(key, value as never) + } + + if (text.length > 0) { + yjsText.insert(0, text, attributes) + } + + return yjsText +} + +const setElementAttributes = ( + element: Y.XmlElement, + attributes: Record +) => { + for (const [key, value] of Object.entries(attributes)) { + if (key === 'type') { + element.setAttribute(SLATE_TYPE_ATTRIBUTE, String(value)) + continue + } + + element.setAttribute(key, value as never) + } +} + +const setYjsAttribute = ( + node: Y.XmlElement | Y.XmlText, + key: string, + value: unknown +) => { + if (key === 'type' && node instanceof Y.XmlElement) { + node.setAttribute(SLATE_TYPE_ATTRIBUTE, String(value)) + return + } + + node.setAttribute(key, value as never) +} + +const removeYjsAttribute = (node: Y.XmlElement | Y.XmlText, key: string) => { + if (key === 'type' && node instanceof Y.XmlElement) { + node.removeAttribute(SLATE_TYPE_ATTRIBUTE) + return + } + + node.removeAttribute(key) +} + +const applyTextFormatPatch = ( + text: Y.XmlText, + patch: Record +) => { + const length = getYjsLength(text) + + if (length === 0) { + return + } + + text.format(0, length, patch as Record) +} + +const setYjsNodeAttributes = ( + node: Y.XmlElement | Y.XmlText, + properties: Record, + newProperties: Record +) => { + const textPatch: Record = {} + + for (const [key, value] of Object.entries(newProperties)) { + if (key === 'children' || key === 'text') { + throw new Error(`Cannot set the "${key}" property on a Yjs node.`) + } + + if (value == null) { + removeYjsAttribute(node, key) + textPatch[key] = null + continue + } + + setYjsAttribute(node, key, value) + + if (node instanceof Y.XmlText) { + textPatch[key] = value + } + } + + for (const key of Object.keys(properties)) { + if (Object.hasOwn(newProperties, key)) { + continue + } + if (key === 'children' || key === 'text') { + throw new Error(`Cannot set the "${key}" property on a Yjs node.`) + } + + removeYjsAttribute(node, key) + + if (node instanceof Y.XmlText) { + textPatch[key] = null + } + } + + if (node instanceof Y.XmlText && Object.keys(textPatch).length > 0) { + applyTextFormatPatch(node, textPatch) + } +} + +const createSplitElement = ( + original: Y.XmlElement, + properties: Record, + children: Array +) => { + const type = + typeof properties.type === 'string' + ? properties.type + : (original.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? original.nodeName) + const element = new Y.XmlElement(String(type)) + + element.setAttribute(SLATE_TYPE_ATTRIBUTE, String(type)) + setElementAttributes(element, properties) + + if (children.length > 0) { + element.insert(0, children) + } + + return element +} + +const getSharedPrefixLength = (left: string, right: string) => { + let index = 0 + + while ( + index < left.length && + index < right.length && + left[index] === right[index] + ) { + index++ + } + + return index +} + +const getSharedSuffixLength = ( + left: string, + right: string, + prefixLength: number +) => { + let length = 0 + + while ( + length < left.length - prefixLength && + length < right.length - prefixLength && + left.at(-1 - length) === right.at(-1 - length) + ) { + length++ + } + + return length +} + +const replaceYjsText = ( + text: Y.XmlText, + previous: string, + next: string, + attributes: Record +) => { + const prefixLength = getSharedPrefixLength(previous, next) + const suffixLength = getSharedSuffixLength(previous, next, prefixLength) + const removeLength = previous.length - prefixLength - suffixLength + const insertText = next.slice(prefixLength, next.length - suffixLength) + + if (removeLength > 0) { + text.delete(prefixLength, removeLength) + } + + if (insertText.length > 0) { + text.insert(prefixLength, insertText, attributes) + } +} + +const replaceTextChildren = ( + children: Array, + oldChildren: ReplaceFragmentOperation['children'], + newChildren: ReplaceFragmentOperation['newChildren'] +) => { + if ( + children.length !== oldChildren.length || + children.length !== newChildren.length || + children.some((child) => !(child instanceof Y.XmlText)) || + oldChildren.some((child) => !isSlateText(child)) || + newChildren.some((child) => !isSlateText(child)) + ) { + return false + } + + children.forEach((child, index) => { + const oldChild = oldChildren[index] + const newChild = newChildren[index] + + if ( + !(child instanceof Y.XmlText) || + !isSlateText(oldChild) || + !isSlateText(newChild) + ) { + return + } + + const attributes = getTextAttributes(newChild) + + setYjsNodeAttributes(child, getTextAttributes(oldChild), attributes) + replaceYjsText(child, oldChild.text, newChild.text, attributes) + }) + + return true +} + +const pathsEqual = (left: readonly number[], right: readonly number[]) => + left.length === right.length && + left.every((part, index) => part === right[index]) + +export const applySlateOperationToYjs = ( + root: Y.XmlElement, + operation: Operation +): YjsTraceEntry | null => { + switch (operation.type) { + case 'insert_text': { + const text = getYjsNode(root, operation.path) + + if (!(text instanceof Y.XmlText)) { + throw new Error('insert_text target is not a Y.XmlText.') + } + + text.insert(operation.offset, operation.text) + + return { mode: 'operation', operationType: operation.type } + } + case 'remove_text': { + const text = getYjsNode(root, operation.path) + + if (!(text instanceof Y.XmlText)) { + throw new Error('remove_text target is not a Y.XmlText.') + } + + text.delete(operation.offset, operation.text.length) + + return { mode: 'operation', operationType: operation.type } + } + case 'insert_node': { + const { index, parent } = getYjsParent(root, operation.path) + + parent.insert(index, [createYjsNode(operation.node)]) + + return { mode: 'operation', operationType: operation.type } + } + case 'remove_node': { + const { index, parent } = getYjsParent(root, operation.path) + const removalMode = removeYjsChild(root, parent, index, operation.node) + + if (removalMode === 'hidden') { + return { + fallback: 'virtual-unwrap-wrapper-remove', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + if (removalMode === 'hidden-parent') { + return { + fallback: 'virtual-move-parent-remove', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + return { mode: 'operation', operationType: operation.type } + } + case 'split_node': { + const target = getYjsNode(root, operation.path) + const { index, parent } = getYjsParent(root, operation.path) + + if (target instanceof Y.XmlText) { + const rightText = getYjsTextContent(target).slice(operation.position) + + if (rightText.length > 0) { + target.delete(operation.position, rightText.length) + } + + parent.insert(index + 1, [ + createYjsText( + rightText, + operation.properties as Record + ), + ]) + + return { mode: 'operation', operationType: operation.type } + } + + const children = getYjsChildren(target) + const rightChildren = children + .slice(operation.position) + .map((child) => cloneYjsNode(child)) + const deleteCount = getYjsLength(target) - operation.position + + if (deleteCount > 0) { + target.delete(operation.position, deleteCount) + } + + parent.insert(index + 1, [ + createSplitElement( + target, + operation.properties as Record, + rightChildren + ), + ]) + + return { mode: 'operation', operationType: operation.type } + } + case 'merge_node': { + const { index, parent } = getYjsParent(root, operation.path) + + if (index === 0) { + throw new Error('Cannot merge the first Yjs child.') + } + + const children = getYjsVisibleChildren(root, parent) + const previous = children[index - 1] + const target = children[index] + + if (!previous || !target) { + throw new Error('Cannot merge a missing Yjs node.') + } + + if (previous instanceof Y.XmlText && target instanceof Y.XmlText) { + return { + fallback: 'text-merge-preserve-yjs-boundary', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + if (previous instanceof Y.XmlElement && target instanceof Y.XmlElement) { + for (const child of getYjsChildren(target)) { + insertYjsChild( + root, + previous, + getYjsLength(previous), + createVirtualYjsMovePlaceholder(child) + ) + } + + hideYjsNode(target) + + return { + fallback: 'virtual-merge-ref', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + throw new Error('Cannot merge Yjs nodes of different kinds.') + } + case 'replace_fragment': { + const target = + operation.path.length === 0 ? root : getYjsNode(root, operation.path) + + if (!(target instanceof Y.XmlElement)) { + throw new Error('replace_fragment target is not a Y.XmlElement.') + } + + const children = getYjsChildren(target) + if ( + replaceTextChildren(children, operation.children, operation.newChildren) + ) { + return { mode: 'operation', operationType: operation.type } + } + + if (getYjsLength(target) > 0) { + target.delete(0, getYjsLength(target)) + } + + if (operation.newChildren.length > 0) { + target.insert(0, operation.newChildren.map(createYjsNode)) + } + + return { + fallback: 'replace-fragment-scoped-replace-identity-risk', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + case 'set_selection': + return null + case 'set_node': { + const node = getYjsNode(root, operation.path) + + setYjsNodeAttributes( + node, + operation.properties as Record, + operation.newProperties as Record + ) + + return { mode: 'operation', operationType: operation.type } + } + case 'replace_children': { + const target = + operation.path.length === 0 ? root : getYjsNode(root, operation.path) + + if (!(target instanceof Y.XmlElement)) { + throw new Error('replace_children target is not a Y.XmlElement.') + } + + const removalModes = operation.children.map((child) => + removeYjsChild(root, target, operation.index, child) + ) + + operation.newChildren.forEach((child, offset) => { + insertYjsChild( + root, + target, + operation.index + offset, + createYjsNode(child) + ) + }) + + if (removalModes.some((mode) => mode !== 'visible')) { + return { + fallback: 'replace-children-virtual-removal', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + return { mode: 'operation', operationType: operation.type } + } + case 'move_node': { + const target = getYjsNode(root, operation.path) + const sourceParentPath = operation.path.slice(0, -1) + const sourceParent = + sourceParentPath.length === 0 + ? root + : getYjsNode(root, sourceParentPath) + const newParentPath = operation.newPath.slice(0, -1) + const newIndex = operation.newPath.at(-1) + const newParent = + newParentPath.length === 0 ? root : getYjsNode(root, newParentPath) + + if ( + sourceParent instanceof Y.XmlElement && + isVirtualYjsChild(target, sourceParent) && + pathsEqual(operation.newPath, sourceParentPath) + ) { + setVirtualYjsUnwrapMove(target, sourceParent) + + return { + fallback: 'virtual-unwrap-ref', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + if (!(newParent instanceof Y.XmlElement)) { + throw new Error('move_node destination parent is not a Y.XmlElement.') + } + if (newIndex === undefined) { + throw new Error('move_node destination is missing an index.') + } + + if (newIndex === 0 && getYjsLength(newParent) === 0) { + setVirtualYjsMove(root, target, newParent) + + return { + fallback: 'virtual-move-ref', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + insertYjsChild( + root, + newParent, + newIndex, + createVirtualYjsMovePlaceholder(target) + ) + + return { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + } +} diff --git a/packages/slate-yjs/src/core/selection.ts b/packages/slate-yjs/src/core/selection.ts new file mode 100644 index 0000000000..63da74ae8b --- /dev/null +++ b/packages/slate-yjs/src/core/selection.ts @@ -0,0 +1,79 @@ +import type { Point, Range } from 'slate' +import * as Y from 'yjs' + +import { getYjsLength, getYjsNode, getYjsVisiblePath } from './document' + +export type YjsRelativeRange = { + anchor: Y.RelativePosition + focus: Y.RelativePosition +} + +export const slatePointToYjsRelativePosition = ( + root: Y.XmlElement, + point: Point +) => { + const target = getYjsNode(root, point.path) + + if (!(target instanceof Y.XmlText)) { + throw new Error('Slate point does not target a Y.XmlText.') + } + + const offset = Math.max(0, Math.min(point.offset, getYjsLength(target))) + + return Y.createRelativePositionFromTypeIndex( + target, + offset, + offset === getYjsLength(target) ? -1 : 0 + ) +} + +export const yjsRelativePositionToSlatePoint = ( + root: Y.XmlElement, + position: Y.RelativePosition +): Point | null => { + if (!root.doc) { + throw new Error('Yjs root must be attached to a Y.Doc.') + } + + const absolute = Y.createAbsolutePositionFromRelativePosition( + position, + root.doc + ) + + if (!absolute || !(absolute.type instanceof Y.XmlText)) { + return null + } + + const path = getYjsVisiblePath(root, absolute.type) + + if (!path) { + return null + } + + return { + path, + offset: Math.max(0, Math.min(absolute.index, getYjsLength(absolute.type))), + } +} + +export const slateRangeToYjsRelativeRange = ( + root: Y.XmlElement, + range: Range +): YjsRelativeRange => ({ + anchor: slatePointToYjsRelativePosition(root, range.anchor), + focus: slatePointToYjsRelativePosition(root, range.focus), +}) + +export const yjsRelativeRangeToSlateRange = ( + root: Y.XmlElement, + range: YjsRelativeRange +): Range | null => { + const anchor = yjsRelativePositionToSlatePoint(root, range.anchor) + const focus = yjsRelativePositionToSlatePoint(root, range.focus) + + if (!anchor || !focus) { + return null + } + + return { anchor, focus } +} diff --git a/packages/slate-yjs/src/core/types.ts b/packages/slate-yjs/src/core/types.ts new file mode 100644 index 0000000000..b6a14874e5 --- /dev/null +++ b/packages/slate-yjs/src/core/types.ts @@ -0,0 +1,100 @@ +import type { Range, Value } from 'slate' +import type * as Y from 'yjs' + +export type YjsAwarenessChange = { + added: number[] + removed: number[] + updated: number[] +} + +export type YjsAwarenessLike = { + clientID?: number + doc?: { clientID: number } + getLocalState: () => Record | null + getStates: () => Map> + off?: (event: 'change', handler: (event: YjsAwarenessChange) => void) => void + on?: (event: 'change', handler: (event: YjsAwarenessChange) => void) => void + setLocalStateField: (field: string, value: unknown) => void +} + +export type YjsAwarenessSelection = { + anchor: unknown + focus: unknown +} + +export type YjsRemoteCursor< + TCursorData extends Record = Record, +> = { + clientId: number + selection: Range | null + data?: TCursorData +} + +export type YjsTraceMode = + | 'operation' + | 'remote-reconcile' + | 'seed' + | 'traceable-fallback' + | 'unsupported' + +export type YjsTraceEntry = { + fallback?: string + mode: YjsTraceMode + operationType?: string +} + +export type YjsExtensionOptions = { + autoSendSelection?: boolean + awareness?: YjsAwarenessLike + awarenessDataField?: string + awarenessSelectionField?: string + clientId?: number | string + doc?: Y.Doc + rootName?: string +} + +export type YjsState = { + awarenessRevision: () => number + clientId: () => number | string + connected: () => boolean + doc: () => Y.Doc + paused: () => boolean + remoteCursor: < + TCursorData extends Record = Record, + >( + clientId: number + ) => YjsRemoteCursor | null + remoteCursors: < + TCursorData extends Record = Record, + >() => YjsRemoteCursor[] + root: () => Y.XmlElement + subscribeAwareness: (listener: () => void) => () => void + trace: () => readonly YjsTraceEntry[] +} + +export type YjsTx = { + clearSelection: () => void + clearTrace: () => void + connect: () => void + disconnect: () => void + pause: () => void + reconcile: () => void + redo: () => void + resume: () => void + sendCursorData: (data: Record | null) => void + sendSelection: ( + range?: Range | null, + data?: Record | null + ) => void + undo: () => void +} + +declare module 'slate' { + interface EditorStateExtensionGroups { + yjs: YjsState + } + + interface EditorTxExtensionGroups { + yjs: YjsTx + } +} diff --git a/packages/slate-yjs/src/core/undo-manager-adapter.ts b/packages/slate-yjs/src/core/undo-manager-adapter.ts new file mode 100644 index 0000000000..3b05085f9c --- /dev/null +++ b/packages/slate-yjs/src/core/undo-manager-adapter.ts @@ -0,0 +1,65 @@ +import type * as Y from 'yjs' + +export const SUPPORTED_YJS_UNDO_MANAGER_VERSION = '13.6.30' + +export type YjsUndoManagerStackItem = { + meta: Map +} + +type YjsUndoManagerWithStacks = Y.UndoManager & { + redoStack: YjsUndoManagerStackItem[] + undoStack: YjsUndoManagerStackItem[] +} + +const isStackItem = (value: unknown): value is YjsUndoManagerStackItem => + typeof value === 'object' && + value !== null && + (value as YjsUndoManagerStackItem).meta instanceof Map + +const assertStack = (value: unknown, name: string) => { + if (!Array.isArray(value) || value.some((item) => !isStackItem(item))) { + throw new Error( + `Unsupported Yjs UndoManager ${name} contract. @slate/yjs pins yjs@${SUPPORTED_YJS_UNDO_MANAGER_VERSION}.` + ) + } + + return value +} + +export const createYjsUndoManagerAdapter = (undoManager: Y.UndoManager) => { + const manager = undoManager as YjsUndoManagerWithStacks + const undo = () => assertStack(manager.undoStack, 'undo') + const redo = () => assertStack(manager.redoStack, 'redo') + + return { + moveRedoToUndo(item: YjsUndoManagerStackItem) { + const stack = redo() + const popped = stack.pop() + + if (popped !== item) { + throw new Error('Cannot move a non-top redo item.') + } + + undo().push(item) + }, + moveUndoToRedo(item: YjsUndoManagerStackItem) { + const stack = undo() + const popped = stack.pop() + + if (popped !== item) { + throw new Error('Cannot move a non-top undo item.') + } + + redo().push(item) + }, + peekRedo() { + return redo().at(-1) ?? null + }, + peekUndo() { + return undo().at(-1) ?? null + }, + storeUndoMeta(key: unknown, value: unknown) { + undo().at(-1)?.meta.set(key, value) + }, + } +} diff --git a/packages/slate-yjs/src/index.ts b/packages/slate-yjs/src/index.ts new file mode 100644 index 0000000000..46d458ad7f --- /dev/null +++ b/packages/slate-yjs/src/index.ts @@ -0,0 +1 @@ +export * from './core' diff --git a/packages/slate-yjs/src/internal/index.ts b/packages/slate-yjs/src/internal/index.ts new file mode 100644 index 0000000000..a00c4b187f --- /dev/null +++ b/packages/slate-yjs/src/internal/index.ts @@ -0,0 +1 @@ +export * from '../core' diff --git a/packages/slate-yjs/src/react/index.ts b/packages/slate-yjs/src/react/index.ts new file mode 100644 index 0000000000..2c081b18c2 --- /dev/null +++ b/packages/slate-yjs/src/react/index.ts @@ -0,0 +1,41 @@ +import { useSyncExternalStore } from 'react' +import type { Editor, EditorCoreStateView } from 'slate' + +import type { YjsRemoteCursor, YjsState } from '../core' + +type YjsStateView = EditorCoreStateView & { + yjs: YjsState +} + +const readYjsState = (editor: Editor, selector: (state: YjsState) => T) => + editor.read((state) => selector((state as YjsStateView).yjs)) + +export const getYjsAwarenessRevision = (editor: Editor) => + readYjsState(editor, (state) => state.awarenessRevision()) + +export function useYjsAwarenessRevision(editor: Editor) { + return useSyncExternalStore( + (listener) => + readYjsState(editor, (state) => state.subscribeAwareness(listener)), + () => getYjsAwarenessRevision(editor), + () => getYjsAwarenessRevision(editor) + ) +} + +export function useYjsRemoteCursor< + TCursorData extends Record = Record, +>(editor: Editor, clientId: number): YjsRemoteCursor | null { + useYjsAwarenessRevision(editor) + + return readYjsState(editor, (state) => + state.remoteCursor(clientId) + ) +} + +export function useYjsRemoteCursors< + TCursorData extends Record = Record, +>(editor: Editor): YjsRemoteCursor[] { + useYjsAwarenessRevision(editor) + + return readYjsState(editor, (state) => state.remoteCursors()) +} diff --git a/packages/slate-yjs/test/awareness-contract.spec.ts b/packages/slate-yjs/test/awareness-contract.spec.ts new file mode 100644 index 0000000000..e0cde132fa --- /dev/null +++ b/packages/slate-yjs/test/awareness-contract.spec.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { Descendant, Range } from 'slate' + +import { + createYjsPeer, + FakeAwareness, + getYjsState, + runYjsUpdate, +} from './support/collaboration' + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const selection = (path = [0, 0], offset = 2): Range => ({ + anchor: { path, offset }, + focus: { path, offset }, +}) + +const createAwarePeer = () => { + const awareness = new FakeAwareness(2) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'b', + numericClientId: 2, + }) + + return { awareness, peer } +} + +const sendRemoteSelection = ( + peer: ReturnType['peer'], + awareness: FakeAwareness, + range: Range, + clientId = 101 +) => { + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range) + awareness.setRemoteState(clientId, { + data: { name: 'Ada' }, + selection: awareness.getLocalState()?.selection, + }) + }) +} + +describe('@slate/yjs awareness contract', () => { + it('publishes local selections as relative positions without changing document trace', () => { + const { awareness, peer } = createAwarePeer() + const range = selection([1, 0], 3) + + runYjsUpdate(peer, (yjs) => { + yjs.clearTrace() + yjs.sendSelection(range, { name: 'B' }) + }) + + assert.deepEqual(awareness.getLocalState()?.data, { name: 'B' }) + assert.deepEqual(getYjsState(peer).trace(), []) + assert.deepEqual(getYjsState(peer).remoteCursors(), []) + }) + + it('projects remote awareness selections to Slate ranges', () => { + const { awareness, peer } = createAwarePeer() + const range = selection([1, 0], 3) + + sendRemoteSelection(peer, awareness, range) + + assert.deepEqual(getYjsState(peer).remoteCursors(), [ + { + clientId: 101, + data: { name: 'Ada' }, + selection: range, + }, + ]) + }) + + it('auto-publishes local selection commits without document operations', () => { + const { awareness, peer } = createAwarePeer() + const range = selection([0, 0], 1) + + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + peer.editor.update((tx) => { + tx.selection.set(range) + }) + awareness.setRemoteState(101, { + selection: awareness.getLocalState()?.selection, + }) + + assert.deepEqual(getYjsState(peer).trace(), []) + assert.deepEqual(getYjsState(peer).remoteCursors()[0]?.selection, range) + }) + + it('does not expose remote cursors while disconnected', () => { + const { awareness, peer } = createAwarePeer() + + sendRemoteSelection(peer, awareness, selection()) + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + + assert.deepEqual(getYjsState(peer).remoteCursors(), []) + + runYjsUpdate(peer, (yjs) => yjs.connect()) + + assert.equal(getYjsState(peer).remoteCursors().length, 1) + }) + + it('increments awareness revision on remote changes', () => { + const { awareness, peer } = createAwarePeer() + const before = getYjsState(peer).awarenessRevision() + + sendRemoteSelection(peer, awareness, selection()) + + assert.equal(getYjsState(peer).awarenessRevision() > before, true) + }) + + it('notifies awareness subscribers on remote changes', () => { + const { awareness, peer } = createAwarePeer() + let notifications = 0 + const unsubscribe = getYjsState(peer).subscribeAwareness(() => { + notifications += 1 + }) + + sendRemoteSelection(peer, awareness, selection()) + unsubscribe() + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + + assert.equal(notifications, 2) + }) + + it('rebases remote selections through virtual moved-node identity', () => { + const { awareness, peer } = createAwarePeer() + + sendRemoteSelection(peer, awareness, selection([0, 0], 2)) + + peer.editor.update((tx) => { + tx.nodes.move({ at: [0], to: [2] }) + }) + + assert.deepEqual(getYjsState(peer).remoteCursors()[0]?.selection, { + anchor: { path: [2, 0], offset: 2 }, + focus: { path: [2, 0], offset: 2 }, + }) + }) + + it('clears the local awareness selection without clearing cursor data', () => { + const { awareness, peer } = createAwarePeer() + + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(selection(), { name: 'B' }) + yjs.clearSelection() + }) + + assert.deepEqual(awareness.getLocalState(), { + data: { name: 'B' }, + selection: null, + }) + }) +}) diff --git a/packages/slate-yjs/test/delete-fragment-contract.spec.ts b/packages/slate-yjs/test/delete-fragment-contract.spec.ts new file mode 100644 index 0000000000..a052c6dad6 --- /dev/null +++ b/packages/slate-yjs/test/delete-fragment-contract.spec.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Descendant, type Operation } from 'slate' +import { Editor } from 'slate/internal' + +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getVisibleYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const createPeer = (clientId: keyof typeof clientIds) => + createYjsPeer({ + children: initialValue(), + clientId, + numericClientId: clientIds[clientId], + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const collectDeleteFragmentOperations = ( + selection: NonNullable['selection']> +) => { + const editor = createEditor() + const operations: Operation[] = [] + + Editor.replace(editor, { + children: initialValue(), + marks: null, + selection: null, + }) + + editor.extend({ + name: 'delete-fragment-operation-capture', + onCommit({ commit }) { + operations.push(...commit.operations) + }, + }) + + editor.update((tx) => { + tx.selection.set(selection) + }) + + operations.length = 0 + + editor.update((tx) => { + tx.fragment.delete() + }) + + return operations.map((operation) => operation.type) +} + +const selectAndDeleteFragment = ( + peer: ReturnType, + selection: NonNullable['selection']> +) => { + peer.editor.update((tx) => { + tx.selection.set(selection) + }) + + peer.editor.update((tx) => { + tx.fragment.delete() + }) +} + +const deleteBetaMiddle = (peer: ReturnType) => { + selectAndDeleteFragment(peer, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 3 }, + }) +} + +const deleteFromAlphaIntoGamma = (peer: ReturnType) => { + selectAndDeleteFragment(peer, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [2, 0], offset: 2 }, + }) +} + +const appendRemoteGamma = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [2, 0], offset: 'gamma'.length } }) + }) +} + +describe('@slate/yjs delete_fragment collaboration contract', () => { + it('characterizes public deleteFragment inside one text as remove_text', () => { + assert.deepEqual( + collectDeleteFragmentOperations({ + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 3 }, + }), + ['remove_text', 'set_selection'] + ) + }) + + it('characterizes public deleteFragment across blocks as text removals, node removal, and merges', () => { + assert.deepEqual( + collectDeleteFragmentOperations({ + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [2, 0], offset: 2 }, + }), + [ + 'remove_text', + 'remove_node', + 'remove_text', + 'merge_node', + 'merge_node', + 'set_selection', + ] + ) + }) + + it('applies local offline deleteFragment without replacing the edited Yjs text node', () => { + const peer = createPeer('b') + const text = getVisibleYjsNodeAt(peer, [1, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + deleteBetaMiddle(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alpha', 'ba', 'gamma']) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), text) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'remove_text' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text inside the end block when an offline deleteFragment reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + deleteFromAlphaIntoGamma(b) + appendRemoteGamma(a) + syncConnectedPeers(peers) + + assert.deepEqual(getParagraphTexts(a), ['alpha', 'beta', 'gamma!']) + assert.deepEqual(getParagraphTexts(b), ['almma']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['almma!']) + assertNoRootSnapshot(b) + }) + + it('undoes and redoes only the local cross-block deleteFragment intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + deleteFromAlphaIntoGamma(b) + appendRemoteGamma(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['almma!']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha', 'beta', 'gamma!']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['almma!']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/insert-fragment-contract.spec.ts b/packages/slate-yjs/test/insert-fragment-contract.spec.ts new file mode 100644 index 0000000000..da0dc2db7e --- /dev/null +++ b/packages/slate-yjs/test/insert-fragment-contract.spec.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant, defineEditorExtension } from 'slate' + +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha')] + +const createPeer = ( + clientId: keyof typeof clientIds, + seedUpdate?: Uint8Array +) => + createYjsPeer({ + children: initialValue(), + clientId, + numericClientId: clientIds[clientId], + seedUpdate, + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const insertFragment = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 'alpha'.length }, + focus: { path: [0, 0], offset: 'alpha'.length }, + }) + }) + peer.editor.update((tx) => { + tx.fragment.insert([{ text: 'Lin fragment' }]) + }) +} + +const appendRemoteText = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert(' Ada', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +const collectInsertFragmentOperations = () => { + const peer = createPeer('b') + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'insert-fragment-operation-recorder', + setup() { + return { + onCommit({ commit }) { + if ( + commit.command?.type === 'insert_fragment' || + commit.operations.some((operation) => + ['insert_node', 'merge_node'].includes(operation.type) + ) + ) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + } + }, + } + }, + }) + ) + insertFragment(peer) + + return operations +} + +describe('@slate/yjs insert_fragment collaboration contract', () => { + it('characterizes public insert_fragment as insert_node then text merge fallback', () => { + assert.deepEqual(collectInsertFragmentOperations(), [ + 'insert_node', + 'set_selection', + 'merge_node', + ]) + }) + + it('applies local offline public insert_fragment without replacing the original Yjs text node', () => { + const peer = createPeer('b') + const text = getYjsNodeAt(peer, [0, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + insertFragment(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alphaLin fragment']) + assert.equal(getYjsNodeAt(peer, [0, 0]), text) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'insert_node' }, + { + fallback: 'text-merge-preserve-yjs-boundary', + mode: 'traceable-fallback', + operationType: 'merge_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline insert_fragment reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + insertFragment(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + assert.deepEqual(getParagraphTexts(a), ['alpha Ada']) + assert.deepEqual(getParagraphTexts(b), ['alphaLin fragment']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha AdaLin fragment']) + assertNoRootSnapshot(b) + }) + + it('recovers insert_fragment convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + insertFragment(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alphaLin fragment']) + }) + + it('undoes and redoes only the local insert_fragment intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + insertFragment(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha AdaLin fragment']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha Ada']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha AdaLin fragment']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/lift-nodes-contract.spec.ts b/packages/slate-yjs/test/lift-nodes-contract.spec.ts new file mode 100644 index 0000000000..f55e9fd609 --- /dev/null +++ b/packages/slate-yjs/test/lift-nodes-contract.spec.ts @@ -0,0 +1,504 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant, defineEditorExtension } from 'slate' +import { Editor } from 'slate/internal' + +import { + assertNoRootSnapshot, + createSeededYjsPeers, + createYjsPeer, + getVisibleYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const section = (...children: Descendant[]): Descendant => ({ + type: 'section', + children, +}) + +const initialValue = () => [ + section(paragraph('alpha'), paragraph('beta')), + paragraph('gamma'), +] + +const onlyChildValue = () => [section(paragraph('alpha'))] + +const tripleChildValue = () => [ + section(paragraph('alpha'), paragraph('beta'), paragraph('gamma')), + paragraph('delta'), +] + +const createPeer = ( + clientId: keyof typeof clientIds, + seedUpdate?: Uint8Array, + children: Descendant[] = initialValue() +) => + createYjsPeer({ + children, + clientId, + numericClientId: clientIds[clientId], + seedUpdate, + }) + +const createPeers = ( + ids: Array, + children: Descendant[] = initialValue() +) => + createSeededYjsPeers({ + children, + clientIds: ids, + numericClientIds: clientIds, + }) + +const topLevelTexts = (peer: ReturnType) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const liftFirstNestedBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.lift({ at: [0, 0] }) + }) +} + +const liftOnlyNestedBlock = liftFirstNestedBlock + +const liftLastNestedBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.lift({ at: [0, 1] }) + }) +} + +const liftMiddleNestedBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.lift({ at: [0, 1] }) + }) +} + +const appendNestedAlpha = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0, 0], offset: 'alpha'.length } }) + }) +} + +const appendNestedBeta = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 1, 0], offset: 'beta'.length } }) + }) +} + +const collectLiftOperations = ( + lift: (peer: ReturnType) => void = liftFirstNestedBlock, + children: Descendant[] = initialValue() +) => { + const peer = createPeer('b', undefined, children) + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'lift-operation-recorder', + setup() { + return { + onCommit({ commit }) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + lift(peer) + + return operations +} + +describe('@slate/yjs liftNodes collaboration contract', () => { + it('characterizes first-child public liftNodes as move_node', () => { + assert.deepEqual(collectLiftOperations(), ['move_node']) + }) + + it('characterizes only-child public liftNodes as move_node then remove_node', () => { + assert.deepEqual( + collectLiftOperations(liftOnlyNestedBlock, onlyChildValue()), + ['move_node', 'remove_node'] + ) + }) + + it('characterizes middle-child public liftNodes as split_node then move_node', () => { + assert.deepEqual( + collectLiftOperations(liftMiddleNestedBlock, tripleChildValue()), + ['split_node', 'move_node'] + ) + }) + + it('applies local offline first-child lift without replacing the original Yjs node', () => { + const peer = createPeer('b') + const original = getVisibleYjsNodeAt(peer, [0, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + liftFirstNestedBlock(peer) + + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + assert.equal(getVisibleYjsNodeAt(peer, [0]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline first-child lift reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftFirstNestedBlock(b) + appendNestedAlpha(a) + syncConnectedPeers(peers) + + assert.deepEqual(topLevelTexts(a), ['alpha!beta', 'gamma']) + assert.deepEqual(topLevelTexts(b), ['alpha', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + } + assertNoRootSnapshot(b) + }) + + it('recovers first-child lift convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftFirstNestedBlock(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + } + }) + + it('undoes and redoes only the local first-child lift intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftFirstNestedBlock(b) + appendNestedAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!beta', 'gamma']) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + } + assertNoRootSnapshot(b) + }) + + it('applies local offline only-child lift without replacing the original Yjs node', () => { + const peer = createPeer('b', undefined, onlyChildValue()) + const original = getVisibleYjsNodeAt(peer, [0, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + liftOnlyNestedBlock(peer) + + assert.deepEqual(topLevelTexts(peer), ['alpha']) + assert.equal(getVisibleYjsNodeAt(peer, [0]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + { + fallback: 'virtual-move-parent-remove', + mode: 'traceable-fallback', + operationType: 'remove_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline only-child lift reconnects', () => { + const peers = createPeers(['a', 'b', 'c'], onlyChildValue()) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftOnlyNestedBlock(b) + appendNestedAlpha(a) + syncConnectedPeers(peers) + + assert.deepEqual(topLevelTexts(a), ['alpha!']) + assert.deepEqual(topLevelTexts(b), ['alpha']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!']) + } + assertNoRootSnapshot(b) + }) + + it('recovers only-child lift convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c'], onlyChildValue()) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftOnlyNestedBlock(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha']) + } + }) + + it('undoes and redoes only the local only-child lift intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c'], onlyChildValue()) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftOnlyNestedBlock(b) + appendNestedAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!']) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!']) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!']) + } + assertNoRootSnapshot(b) + }) + + it('applies local offline last-child lift without replacing the original Yjs node', () => { + const peer = createPeer('b') + const original = getVisibleYjsNodeAt(peer, [0, 1]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + liftLastNestedBlock(peer) + + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + assert.equal(getVisibleYjsNodeAt(peer, [1]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline last-child lift reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftLastNestedBlock(b) + appendNestedBeta(a) + syncConnectedPeers(peers) + + assert.deepEqual(topLevelTexts(a), ['alphabeta!', 'gamma']) + assert.deepEqual(topLevelTexts(b), ['alpha', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta!', 'gamma']) + } + assertNoRootSnapshot(b) + }) + + it('recovers last-child lift convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftLastNestedBlock(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + } + }) + + it('undoes and redoes only the local last-child lift intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftLastNestedBlock(b) + appendNestedBeta(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta!', 'gamma']) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alphabeta!', 'gamma']) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta!', 'gamma']) + } + assertNoRootSnapshot(b) + }) + + it('applies local offline middle-child lift through split_node and move_node', () => { + const peer = createPeer('b', undefined, tripleChildValue()) + const original = getVisibleYjsNodeAt(peer, [0, 1]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + liftMiddleNestedBlock(peer) + + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma', 'delta']) + assert.equal(getVisibleYjsNodeAt(peer, [1]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'split_node' }, + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline middle-child lift reconnects', () => { + const peers = createPeers(['a', 'b', 'c'], tripleChildValue()) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftMiddleNestedBlock(b) + appendNestedBeta(a) + syncConnectedPeers(peers) + + assert.deepEqual(topLevelTexts(a), ['alphabeta!gamma', 'delta']) + assert.deepEqual(topLevelTexts(b), ['alpha', 'beta', 'gamma', 'delta']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), [ + 'alpha', + 'beta!', + 'gamma', + 'delta', + ]) + } + assertNoRootSnapshot(b) + }) + + it('recovers middle-child lift convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c'], tripleChildValue()) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftMiddleNestedBlock(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma', 'delta']) + } + }) + + it('undoes and redoes only the local middle-child lift intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c'], tripleChildValue()) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + liftMiddleNestedBlock(b) + appendNestedBeta(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), [ + 'alpha', + 'beta!', + 'gamma', + 'delta', + ]) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alphabeta!gamma', 'delta']) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), [ + 'alpha', + 'beta!', + 'gamma', + 'delta', + ]) + } + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/merge-node-contract.spec.ts b/packages/slate-yjs/test/merge-node-contract.spec.ts new file mode 100644 index 0000000000..5743c707c3 --- /dev/null +++ b/packages/slate-yjs/test/merge-node-contract.spec.ts @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Descendant } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { createYjsExtension } from '../src' + +type Peer = { + doc: Y.Doc + editor: ReturnType +} + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha'), paragraph('beta')] + +const textMergeValue = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }, { text: 'beta' }], + }, +] + +const createPeer = ( + clientId: string, + seedUpdate?: Uint8Array, + children: Descendant[] = initialValue() +): Peer => { + const editor = createEditor() + + Editor.replace(editor, { + children, + selection: null, + marks: null, + }) + + const doc = new Y.Doc() + + if (seedUpdate) { + Y.applyUpdate(doc, seedUpdate) + } + + editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) + + return { doc, editor } +} + +const createPeers = ( + clientIds: string[], + children: Descendant[] = initialValue() +) => { + const [firstClientId, ...remainingClientIds] = clientIds + + if (!firstClientId) { + return [] + } + + const firstPeer = createPeer(firstClientId, undefined, children) + const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) + + return [ + firstPeer, + ...remainingClientIds.map((clientId) => + createPeer(clientId, seedUpdate, children) + ), + ] +} + +const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) + +const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { + peer.editor.update((tx) => { + fn((tx as any).yjs) + }) +} + +const paragraphTexts = (peer: Peer) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const yjsNodeAt = (peer: Peer, path: number[]): Y.XmlElement | Y.XmlText => { + let current: Y.XmlElement | Y.XmlText = yjsState(peer).root() + + for (const index of path) { + if (current instanceof Y.XmlText) { + throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) + } + + const child = current + .toArray() + .filter( + (value): value is Y.XmlElement | Y.XmlText => + value instanceof Y.XmlElement || value instanceof Y.XmlText + )[index] + + if (!child) { + throw new Error(`No Yjs node at ${path.join('.')}`) + } + + current = child + } + + return current +} + +const assertNoRootSnapshot = (peer: Peer) => { + assert.equal( + yjsState(peer) + .trace() + .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), + false + ) +} + +const syncConnected = (peers: Peer[]) => { + for (const source of peers) { + if (!yjsState(source).connected()) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !yjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } +} + +const assertAllTexts = (peers: Peer[], expected: string[]) => { + for (const peer of peers) { + assert.deepEqual(paragraphTexts(peer), expected) + } +} + +const mergeSecondParagraph = (peer: Peer) => { + peer.editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) +} + +const mergeRightText = (peer: Peer) => { + peer.editor.update((tx) => { + tx.operations.replay([ + { + path: [0, 1], + position: 'alpha'.length, + properties: {}, + type: 'merge_node', + }, + ]) + }) +} + +const appendRemoteTextToLeftParagraph = (peer: Peer) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +describe('@slate/yjs merge_node collaboration contract', () => { + it('applies local offline public merge without a root snapshot fallback', () => { + const peer = createPeer('b') + const survivor = yjsNodeAt(peer, [0]) + + yjsUpdate(peer, (yjs) => yjs.disconnect()) + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + mergeSecondParagraph(peer) + + assert.deepEqual(paragraphTexts(peer), ['alphabeta']) + assert.equal(yjsNodeAt(peer, [0]), survivor) + assert.deepEqual(yjsState(peer).trace(), [ + { + fallback: 'virtual-merge-ref', + mode: 'traceable-fallback', + operationType: 'merge_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote survivor edits when an offline merge reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + mergeSecondParagraph(b) + appendRemoteTextToLeftParagraph(a) + syncConnected(peers) + + assert.deepEqual(paragraphTexts(a), ['alpha!', 'beta']) + assert.deepEqual(paragraphTexts(b), ['alphabeta']) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alpha!beta']) + assertNoRootSnapshot(b) + }) + + it('recovers merge convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + mergeSecondParagraph(b) + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alphabeta']) + }) + + it('undoes and redoes only the local merge intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + mergeSecondParagraph(b) + appendRemoteTextToLeftParagraph(a) + syncConnected(peers) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!beta']) + + yjsUpdate(b, (yjs) => yjs.undo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!', 'beta']) + + yjsUpdate(b, (yjs) => yjs.redo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!beta']) + assertNoRootSnapshot(b) + }) + + it('keeps raw text merge_node in a traceable identity-preserving fallback', () => { + const peers = createPeers(['a', 'b', 'c'], textMergeValue()) + const [a, b] = peers + const survivor = yjsNodeAt(b, [0, 0]) + const rightText = yjsNodeAt(b, [0, 1]) + + yjsUpdate(b, (yjs) => yjs.disconnect()) + yjsUpdate(b, (yjs) => yjs.clearTrace()) + mergeRightText(b) + appendRemoteTextToLeftParagraph(a) + syncConnected(peers) + + assert.deepEqual(paragraphTexts(a), ['alpha!beta']) + assert.deepEqual(paragraphTexts(b), ['alphabeta']) + assert.equal(yjsNodeAt(b, [0, 0]), survivor) + assert.equal(yjsNodeAt(b, [0, 1]), rightText) + assert.deepEqual(yjsState(b).trace(), [ + { + fallback: 'text-merge-preserve-yjs-boundary', + mode: 'traceable-fallback', + operationType: 'merge_node', + }, + ]) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!beta']) + + yjsUpdate(b, (yjs) => yjs.undo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!beta']) + + yjsUpdate(b, (yjs) => yjs.redo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!beta']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/move-node-contract.spec.ts b/packages/slate-yjs/test/move-node-contract.spec.ts new file mode 100644 index 0000000000..6dde074d53 --- /dev/null +++ b/packages/slate-yjs/test/move-node-contract.spec.ts @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant, defineEditorExtension } from 'slate' +import { Editor } from 'slate/internal' + +import { + assertNoRootSnapshot, + createSeededYjsPeers, + createYjsPeer, + getVisibleYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const section = (...children: Descendant[]): Descendant => ({ + type: 'section', + children, +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const nestedInitialValue = () => [ + section(paragraph('alpha'), paragraph('beta')), + section(paragraph('gamma')), +] + +const createPeer = ( + clientId: keyof typeof clientIds, + seedUpdate?: Uint8Array, + children: Descendant[] = initialValue() +) => + createYjsPeer({ + children, + clientId, + numericClientId: clientIds[clientId], + seedUpdate, + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const createNestedPeers = (ids: Array) => + createSeededYjsPeers({ + children: nestedInitialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const topLevelTexts = (peer: ReturnType) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const nestedTexts = (peer: ReturnType) => + Editor.getSnapshot(peer.editor).children.map((node, index) => + 'children' in node + ? node.children.map((_, childIndex) => + Editor.string(peer.editor, [index, childIndex]) + ) + : [] + ) + +const moveFirstBlockToEnd = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.move({ at: [0], to: [2] }) + }) +} + +const moveNestedBlockToSecondSection = ( + peer: ReturnType +) => { + peer.editor.update((tx) => { + tx.nodes.move({ at: [0, 0], to: [1, 1] }) + }) +} + +const appendRemoteAlpha = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +const appendNestedRemoteAlpha = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0, 0], offset: 'alpha'.length } }) + }) +} + +const collectMoveOperations = () => { + const peer = createPeer('b') + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'move-operation-recorder', + setup() { + return { + onCommit({ commit }) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + moveFirstBlockToEnd(peer) + + return operations +} + +describe('@slate/yjs move_node collaboration contract', () => { + it('characterizes public moveNodes as move_node', () => { + assert.deepEqual(collectMoveOperations(), ['move_node']) + }) + + it('applies local offline same-parent move without replacing the original Yjs node', () => { + const peer = createPeer('b') + const original = getVisibleYjsNodeAt(peer, [0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + moveFirstBlockToEnd(peer) + + assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha']) + assert.equal(getVisibleYjsNodeAt(peer, [2]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline same-parent move reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + moveFirstBlockToEnd(b) + appendRemoteAlpha(a) + syncConnectedPeers(peers) + + assert.deepEqual(topLevelTexts(a), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(topLevelTexts(b), ['beta', 'gamma', 'alpha']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha!']) + } + assertNoRootSnapshot(b) + }) + + it('undoes and redoes only the local same-parent move intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + moveFirstBlockToEnd(b) + appendRemoteAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha!']) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha!']) + } + assertNoRootSnapshot(b) + }) + + it('applies local offline cross-parent move without replacing the original Yjs node', () => { + const peer = createPeer('b', undefined, nestedInitialValue()) + const original = getVisibleYjsNodeAt(peer, [0, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + moveNestedBlockToSecondSection(peer) + + assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha']]) + assert.equal(getVisibleYjsNodeAt(peer, [1, 1]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline cross-parent move reconnects', () => { + const peers = createNestedPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + moveNestedBlockToSecondSection(b) + appendNestedRemoteAlpha(a) + syncConnectedPeers(peers) + + assert.deepEqual(nestedTexts(a), [['alpha!', 'beta'], ['gamma']]) + assert.deepEqual(nestedTexts(b), [['beta'], ['gamma', 'alpha']]) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha!']]) + } + assertNoRootSnapshot(b) + }) + + it('undoes and redoes only the local cross-parent move intent after reconnect', () => { + const peers = createNestedPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + moveNestedBlockToSecondSection(b) + appendNestedRemoteAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha!']]) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(nestedTexts(peer), [['alpha!', 'beta'], ['gamma']]) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha!']]) + } + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/package-config-contract.spec.ts b/packages/slate-yjs/test/package-config-contract.spec.ts new file mode 100644 index 0000000000..cf053c3a84 --- /dev/null +++ b/packages/slate-yjs/test/package-config-contract.spec.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +const readJson = (path: string) => + JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Record< + string, + any + > + +describe('@slate/yjs package config contract', () => { + it('pins Yjs to the audited UndoManager stack contract version', () => { + const rootPackage = readJson('../../../package.json') + const yjsPackage = readJson('../package.json') + + assert.equal(rootPackage.devDependencies?.yjs, '13.6.30') + assert.equal(yjsPackage.dependencies?.yjs, '13.6.30') + assert.equal(yjsPackage.peerDependencies?.yjs, '13.6.30') + }) + + it('does not resolve site Yjs imports through package-local node_modules', () => { + const tsconfig = readJson('../../../site/tsconfig.json') + const yjsAlias = tsconfig.compilerOptions?.paths?.yjs + + assert.equal(yjsAlias, undefined) + }) +}) diff --git a/packages/slate-yjs/test/remove-node-contract.spec.ts b/packages/slate-yjs/test/remove-node-contract.spec.ts new file mode 100644 index 0000000000..23a952dcc0 --- /dev/null +++ b/packages/slate-yjs/test/remove-node-contract.spec.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Descendant } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { createYjsExtension } from '../src' + +type Peer = { + doc: Y.Doc + editor: ReturnType +} + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => { + const editor = createEditor() + + Editor.replace(editor, { + children: initialValue(), + selection: null, + marks: null, + }) + + const doc = new Y.Doc() + + if (seedUpdate) { + Y.applyUpdate(doc, seedUpdate) + } + + editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) + + return { doc, editor } +} + +const createPeers = (clientIds: string[]) => { + const [firstClientId, ...remainingClientIds] = clientIds + + if (!firstClientId) { + return [] + } + + const firstPeer = createPeer(firstClientId) + const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) + + return [ + firstPeer, + ...remainingClientIds.map((clientId) => createPeer(clientId, seedUpdate)), + ] +} + +const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) + +const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { + peer.editor.update((tx) => { + fn((tx as any).yjs) + }) +} + +const paragraphTexts = (peer: Peer) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const assertNoRootSnapshot = (peer: Peer) => { + assert.equal( + yjsState(peer) + .trace() + .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), + false + ) +} + +const syncConnected = (peers: Peer[]) => { + for (const source of peers) { + if (!yjsState(source).connected()) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !yjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } +} + +const assertAllTexts = (peers: Peer[], expected: string[]) => { + for (const peer of peers) { + assert.deepEqual(paragraphTexts(peer), expected) + } +} + +const removeMiddleBlock = (peer: Peer) => { + peer.editor.update((tx) => { + tx.nodes.remove({ at: [1] }) + }) +} + +const insertRemoteText = (peer: Peer) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +describe('@slate/yjs remove_node collaboration contract', () => { + it('applies local offline remove_node without a root snapshot fallback', () => { + const peer = createPeer('b') + + yjsUpdate(peer, (yjs) => yjs.disconnect()) + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + removeMiddleBlock(peer) + + assert.deepEqual(paragraphTexts(peer), ['alpha', 'gamma']) + assert.deepEqual(yjsState(peer).trace(), [ + { mode: 'operation', operationType: 'remove_node' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote sibling edits when an offline remove_node reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + removeMiddleBlock(b) + insertRemoteText(a) + syncConnected(peers) + + assert.deepEqual(paragraphTexts(a), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(paragraphTexts(b), ['alpha', 'gamma']) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alpha!', 'gamma']) + assertNoRootSnapshot(b) + }) + + it('recovers convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + removeMiddleBlock(b) + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alpha', 'gamma']) + }) + + it('undoes and redoes only the local remove_node intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + removeMiddleBlock(b) + insertRemoteText(a) + syncConnected(peers) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!', 'gamma']) + + yjsUpdate(b, (yjs) => yjs.undo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!', 'beta', 'gamma']) + + yjsUpdate(b, (yjs) => yjs.redo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha!', 'gamma']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/replace-fragment-contract.spec.ts b/packages/slate-yjs/test/replace-fragment-contract.spec.ts new file mode 100644 index 0000000000..448cc4da5a --- /dev/null +++ b/packages/slate-yjs/test/replace-fragment-contract.spec.ts @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Descendant, type Operation } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { createYjsExtension } from '../src' + +type Peer = { + doc: Y.Doc + editor: ReturnType +} + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha')] + +const multiLeafValue = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }, { bold: true, text: ' beta' }], + } as Descendant, +] + +const createPeer = ( + clientId: keyof typeof clientIds, + seedUpdate?: Uint8Array, + children = initialValue() +): Peer => { + const editor = createEditor() + + Editor.replace(editor, { + children, + selection: null, + marks: null, + }) + + const doc = new Y.Doc() + + doc.clientID = clientIds[clientId] + + if (seedUpdate) { + Y.applyUpdate(doc, seedUpdate) + } + + editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) + + return { doc, editor } +} + +const createPeers = (ids: Array) => { + const [firstClientId, ...remainingClientIds] = ids + + if (!firstClientId) { + return [] + } + + const firstPeer = createPeer(firstClientId) + const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) + + return [ + firstPeer, + ...remainingClientIds.map((clientId) => createPeer(clientId, seedUpdate)), + ] +} + +const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) + +const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { + peer.editor.update((tx) => { + fn((tx as any).yjs) + }) +} + +const paragraphTexts = (peer: Peer) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const yjsNodeAt = (peer: Peer, path: number[]): Y.XmlElement | Y.XmlText => { + let current: Y.XmlElement | Y.XmlText = yjsState(peer).root() + + for (const index of path) { + if (current instanceof Y.XmlText) { + throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) + } + + const child = current + .toArray() + .filter( + (value): value is Y.XmlElement | Y.XmlText => + value instanceof Y.XmlElement || value instanceof Y.XmlText + )[index] + + if (!child) { + throw new Error(`No Yjs node at ${path.join('.')}`) + } + + current = child + } + + return current +} + +const assertNoRootSnapshot = (peer: Peer) => { + assert.equal( + yjsState(peer) + .trace() + .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), + false + ) +} + +const syncConnected = (peers: Peer[]) => { + for (const source of peers) { + if (!yjsState(source).connected()) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !yjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } +} + +const assertAllTexts = (peers: Peer[], expected: string[]) => { + for (const peer of peers) { + assert.deepEqual(paragraphTexts(peer), expected) + } +} + +const replaceAlphaWithFragment = (peer: Peer) => { + const operation: Operation = { + children: [{ text: 'alpha' }], + newChildren: [{ text: 'alphaLin fragment' }], + newSelection: null, + path: [0], + selection: null, + type: 'replace_fragment', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + +const replaceMultiLeafTextWithFragment = (peer: Peer) => { + const operation: Operation = { + children: [{ text: 'alpha' }, { bold: true, text: ' beta' }], + newChildren: [{ text: 'alphaLin' }, { bold: true, text: ' betaAda' }], + newSelection: null, + path: [0], + selection: null, + type: 'replace_fragment', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + +const replaceRootWithFallback = (peer: Peer) => { + const operation: Operation = { + children: initialValue(), + newChildren: [paragraph('bravo'), paragraph('charlie')], + newSelection: null, + path: [], + selection: null, + type: 'replace_fragment', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + +const appendRemoteText = (peer: Peer) => { + peer.editor.update((tx) => { + tx.text.insert(' Ada', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +describe('@slate/yjs replace_fragment collaboration contract', () => { + it('applies local offline single-text replace_fragment without replacing the Yjs text node', () => { + const peer = createPeer('b') + const text = yjsNodeAt(peer, [0, 0]) + + yjsUpdate(peer, (yjs) => yjs.disconnect()) + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + replaceAlphaWithFragment(peer) + + assert.deepEqual(paragraphTexts(peer), ['alphaLin fragment']) + assert.equal(yjsNodeAt(peer, [0, 0]), text) + assert.deepEqual(yjsState(peer).trace(), [ + { mode: 'operation', operationType: 'replace_fragment' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves every Yjs text node for same-width multi-leaf replace_fragment', () => { + const peer = createPeer('b', undefined, multiLeafValue()) + + const firstText = yjsNodeAt(peer, [0, 0]) + const secondText = yjsNodeAt(peer, [0, 1]) + + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + replaceMultiLeafTextWithFragment(peer) + + assert.deepEqual(paragraphTexts(peer), ['alphaLin betaAda']) + assert.equal(yjsNodeAt(peer, [0, 0]), firstText) + assert.equal(yjsNodeAt(peer, [0, 1]), secondText) + assert.deepEqual(yjsState(peer).trace(), [ + { mode: 'operation', operationType: 'replace_fragment' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline replace_fragment reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + replaceAlphaWithFragment(b) + appendRemoteText(a) + syncConnected(peers) + + assert.deepEqual(paragraphTexts(a), ['alpha Ada']) + assert.deepEqual(paragraphTexts(b), ['alphaLin fragment']) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alpha AdaLin fragment']) + assertNoRootSnapshot(b) + }) + + it('recovers replace_fragment convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + replaceAlphaWithFragment(b) + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alphaLin fragment']) + }) + + it('undoes and redoes only the local replace_fragment intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + replaceAlphaWithFragment(b) + appendRemoteText(a) + syncConnected(peers) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['alpha AdaLin fragment']) + + yjsUpdate(b, (yjs) => yjs.undo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha Ada']) + + yjsUpdate(b, (yjs) => yjs.redo()) + syncConnected(peers) + assertAllTexts(peers, ['alpha AdaLin fragment']) + assertNoRootSnapshot(b) + }) + + it('uses a traceable fallback for broad replace_fragment replacement', () => { + const peer = createPeer('b') + + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + replaceRootWithFallback(peer) + + assert.deepEqual(paragraphTexts(peer), ['bravo', 'charlie']) + assert.deepEqual(yjsState(peer).trace(), [ + { + fallback: 'replace-fragment-scoped-replace-identity-risk', + mode: 'traceable-fallback', + operationType: 'replace_fragment', + }, + ]) + assertNoRootSnapshot(peer) + }) +}) diff --git a/packages/slate-yjs/test/selection-contract.spec.ts b/packages/slate-yjs/test/selection-contract.spec.ts new file mode 100644 index 0000000000..cde9161963 --- /dev/null +++ b/packages/slate-yjs/test/selection-contract.spec.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { Descendant, Range } from 'slate' + +import { + slatePointToYjsRelativePosition, + slateRangeToYjsRelativeRange, + yjsRelativePositionToSlatePoint, + yjsRelativeRangeToSlateRange, +} from '../src' +import { + createSeededYjsPeers, + createYjsPeer, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const createPeer = (clientId: keyof typeof clientIds) => + createYjsPeer({ + children: initialValue(), + clientId, + numericClientId: clientIds[clientId], + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const root = (peer: ReturnType) => getYjsState(peer).root() + +const moveFirstBlockToEnd = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.move({ at: [0], to: [2] }) + }) +} + +const insertInsideAlpha = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 2 } }) + }) +} + +const removeFirstBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.remove({ at: [0] }) + }) +} + +describe('@slate/yjs selection relative-position contract', () => { + it('round trips a Slate point through a Yjs relative position', () => { + const peer = createPeer('b') + const point = { path: [0, 0], offset: 3 } + const relative = slatePointToYjsRelativePosition(root(peer), point) + + assert.deepEqual( + yjsRelativePositionToSlatePoint(root(peer), relative), + point + ) + }) + + it('round trips a Slate range without changing anchor/focus direction', () => { + const peer = createPeer('b') + const range: Range = { + anchor: { path: [1, 0], offset: 4 }, + focus: { path: [0, 0], offset: 1 }, + } + const relative = slateRangeToYjsRelativeRange(root(peer), range) + + assert.deepEqual(yjsRelativeRangeToSlateRange(root(peer), relative), range) + }) + + it('rebases a stored point across a concurrent text insert', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + const relative = slatePointToYjsRelativePosition(root(b), { + path: [0, 0], + offset: 3, + }) + + insertInsideAlpha(a) + syncConnectedPeers(peers) + + assert.deepEqual(yjsRelativePositionToSlatePoint(root(b), relative), { + path: [0, 0], + offset: 4, + }) + }) + + it('resolves a stored point through virtual moved-node identity', () => { + const peer = createPeer('b') + const relative = slatePointToYjsRelativePosition(root(peer), { + path: [0, 0], + offset: 2, + }) + + moveFirstBlockToEnd(peer) + + assert.deepEqual(yjsRelativePositionToSlatePoint(root(peer), relative), { + path: [2, 0], + offset: 2, + }) + }) + + it('returns null when the relative position target is no longer visible', () => { + const peer = createPeer('b') + const relative = slatePointToYjsRelativePosition(root(peer), { + path: [0, 0], + offset: 2, + }) + + removeFirstBlock(peer) + + assert.equal(yjsRelativePositionToSlatePoint(root(peer), relative), null) + }) + + it('does not record selection-only conversions in the Yjs operation trace', () => { + const peer = createPeer('b') + + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + slateRangeToYjsRelativeRange(root(peer), { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) + + assert.deepEqual(getYjsState(peer).trace(), []) + }) +}) diff --git a/packages/slate-yjs/test/set-node-contract.spec.ts b/packages/slate-yjs/test/set-node-contract.spec.ts new file mode 100644 index 0000000000..79e4c5d2a0 --- /dev/null +++ b/packages/slate-yjs/test/set-node-contract.spec.ts @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + type Descendant, + defineEditorExtension, + type Element, + NodeApi, +} from 'slate' +import { Editor } from 'slate/internal' + +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getVisibleYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = ( + text: string, + attributes: Record = {} +): Descendant => ({ + ...attributes, + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha')] +const roleValue = () => [paragraph('alpha', { role: 'title' })] + +const createPeer = ( + clientId: keyof typeof clientIds, + children: Descendant[] = initialValue() +) => + createYjsPeer({ + children, + clientId, + numericClientId: clientIds[clientId], + }) + +const createPeers = ( + ids: Array, + children: Descendant[] = initialValue() +) => + createSeededYjsPeers({ + children, + clientIds: ids, + numericClientIds: clientIds, + }) + +const setHeading = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.set({ role: 'title', type: 'heading-one' }, { at: [0] }) + }) +} + +const unsetRole = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.unset('role' as never, { at: [0] }) + }) +} + +const setTextMark = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.set({ bold: true } as never, { at: [0, 0], match: NodeApi.isText }) + }) +} + +const appendRemoteText = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +describe('@slate/yjs set_node collaboration contract', () => { + it('characterizes public setNodes as set_node', () => { + const peer = createPeer('b') + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'set-node-operation-recorder', + setup() { + return { + onCommit({ commit }) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + setHeading(peer) + + assert.deepEqual(operations, ['set_node']) + }) + + it('applies local offline element set_node without replacing the Yjs element', () => { + const peer = createPeer('b') + const element = getVisibleYjsNodeAt(peer, [0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + setHeading(peer) + + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'heading-one', role: 'title', children: [{ text: 'alpha' }] }, + ]) + assert.equal(getVisibleYjsNodeAt(peer, [0]), element) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'set_node' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline element set_node reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + setHeading(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + assert.deepEqual(getParagraphTexts(a), ['alpha!']) + assert.deepEqual(Editor.getSnapshot(b.editor).children, [ + { type: 'heading-one', role: 'title', children: [{ text: 'alpha' }] }, + ]) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { + type: 'heading-one', + role: 'title', + children: [{ text: 'alpha!' }], + }, + ]) + } + assertNoRootSnapshot(b) + }) + + it('recovers element set_node convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + setHeading(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'heading-one', role: 'title', children: [{ text: 'alpha' }] }, + ]) + } + }) + + it('undoes and redoes only the local element set_node intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + setHeading(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { + type: 'heading-one', + role: 'title', + children: [{ text: 'alpha!' }], + }, + ]) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'paragraph', children: [{ text: 'alpha!' }] }, + ]) + } + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { + type: 'heading-one', + role: 'title', + children: [{ text: 'alpha!' }], + }, + ]) + } + assertNoRootSnapshot(b) + }) + + it('characterizes public unsetNodes as set_node', () => { + const peer = createPeer('b', roleValue()) + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'unset-node-operation-recorder', + setup() { + return { + onCommit({ commit }) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + unsetRole(peer) + + assert.deepEqual(operations, ['set_node']) + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'paragraph', children: [{ text: 'alpha' }] }, + ]) + }) + + it('applies local offline text mark set_node without replacing the Yjs text node', () => { + const peer = createPeer('b') + const text = getVisibleYjsNodeAt(peer, [0, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + setTextMark(peer) + + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'paragraph', children: [{ bold: true, text: 'alpha' }] }, + ]) + assert.equal(getVisibleYjsNodeAt(peer, [0, 0]), text) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'set_node' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('syncs text mark set_node through reconnect and undo without root snapshots', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + setTextMark(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'paragraph', children: [{ bold: true, text: 'alpha' }] }, + ]) + } + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha']) + for (const peer of peers) { + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { type: 'paragraph', children: [{ text: 'alpha' }] }, + ]) + } + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/simple-operations-contract.spec.ts b/packages/slate-yjs/test/simple-operations-contract.spec.ts new file mode 100644 index 0000000000..81465ca0f7 --- /dev/null +++ b/packages/slate-yjs/test/simple-operations-contract.spec.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant, type Operation } from 'slate' + +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getVisibleYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const createPeer = (clientId: keyof typeof clientIds) => + createYjsPeer({ + children: initialValue(), + clientId, + numericClientId: clientIds[clientId], + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const appendRemoteAlpha = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +const insertBetaBang = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [1, 0], offset: 'beta'.length } }) + }) +} + +const removeBetaMiddle = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.delete({ at: { path: [1, 0], offset: 1 }, distance: 2 }) + }) +} + +const insertMiddleBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.insert([paragraph('bravo')], { at: [1] }) + }) +} + +const replaceMiddleBlock = (peer: ReturnType) => { + const operation: Operation = { + children: [paragraph('beta')], + index: 1, + newChildren: [paragraph('bravo')], + newSelection: null, + path: [], + selection: null, + type: 'replace_children', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + +describe('@slate/yjs simple operation collaboration contract', () => { + it('applies local offline insert_text in place without a root snapshot fallback', () => { + const peer = createPeer('b') + const text = getVisibleYjsNodeAt(peer, [1, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + insertBetaBang(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alpha', 'beta!', 'gamma']) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), text) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'insert_text' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('reconnects, undoes, and redoes insert_text while preserving remote edits', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + insertBetaBang(b) + appendRemoteAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'beta!', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'beta!', 'gamma']) + assertNoRootSnapshot(b) + }) + + it('applies local offline remove_text in place without a root snapshot fallback', () => { + const peer = createPeer('b') + const text = getVisibleYjsNodeAt(peer, [1, 0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + removeBetaMiddle(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alpha', 'ba', 'gamma']) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), text) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'remove_text' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('reconnects, undoes, and redoes remove_text while preserving remote edits', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + removeBetaMiddle(b) + appendRemoteAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'ba', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'ba', 'gamma']) + assertNoRootSnapshot(b) + }) + + it('applies local offline insert_node without replacing existing Yjs siblings', () => { + const peer = createPeer('b') + const alpha = getVisibleYjsNodeAt(peer, [0]) + const beta = getVisibleYjsNodeAt(peer, [1]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + insertMiddleBlock(peer) + + assert.deepEqual(getParagraphTexts(peer), [ + 'alpha', + 'bravo', + 'beta', + 'gamma', + ]) + assert.equal(getVisibleYjsNodeAt(peer, [0]), alpha) + assert.equal(getVisibleYjsNodeAt(peer, [2]), beta) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'insert_node' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('reconnects, undoes, and redoes insert_node while preserving remote edits', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + insertMiddleBlock(b) + appendRemoteAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'bravo', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'bravo', 'beta', 'gamma']) + assertNoRootSnapshot(b) + }) + + it('applies local offline replace_children while preserving unaffected Yjs siblings', () => { + const peer = createPeer('b') + const alpha = getVisibleYjsNodeAt(peer, [0]) + const gamma = getVisibleYjsNodeAt(peer, [2]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + replaceMiddleBlock(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alpha', 'bravo', 'gamma']) + assert.equal(getVisibleYjsNodeAt(peer, [0]), alpha) + assert.equal(getVisibleYjsNodeAt(peer, [2]), gamma) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'replace_children' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('reconnects, undoes, and redoes replace_children while preserving remote edits', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + replaceMiddleBlock(b) + appendRemoteAlpha(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'bravo', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!', 'bravo', 'gamma']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts new file mode 100644 index 0000000000..f1891a24a7 --- /dev/null +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Descendant } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { createYjsExtension } from '../src' + +type Peer = { + doc: Y.Doc + editor: ReturnType +} + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alphabeta')] + +const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => { + const editor = createEditor() + + Editor.replace(editor, { + children: initialValue(), + selection: null, + marks: null, + }) + + const doc = new Y.Doc() + + if (seedUpdate) { + Y.applyUpdate(doc, seedUpdate) + } + + editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) + + return { doc, editor } +} + +const createPeers = (clientIds: string[]) => { + const [firstClientId, ...remainingClientIds] = clientIds + + if (!firstClientId) { + return [] + } + + const firstPeer = createPeer(firstClientId) + const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) + + return [ + firstPeer, + ...remainingClientIds.map((clientId) => createPeer(clientId, seedUpdate)), + ] +} + +const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) + +const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { + peer.editor.update((tx) => { + fn((tx as any).yjs) + }) +} + +const paragraphTexts = (peer: Peer) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const yjsNodeAt = (peer: Peer, path: number[]): Y.XmlElement | Y.XmlText => { + let current: Y.XmlElement | Y.XmlText = yjsState(peer).root() + + for (const index of path) { + if (current instanceof Y.XmlText) { + throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) + } + + const child = current + .toArray() + .filter( + (value): value is Y.XmlElement | Y.XmlText => + value instanceof Y.XmlElement || value instanceof Y.XmlText + )[index] + + if (!child) { + throw new Error(`No Yjs node at ${path.join('.')}`) + } + + current = child + } + + return current +} + +const assertNoRootSnapshot = (peer: Peer) => { + assert.equal( + yjsState(peer) + .trace() + .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), + false + ) +} + +const syncConnected = (peers: Peer[]) => { + for (const source of peers) { + if (!yjsState(source).connected()) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !yjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } +} + +const assertAllTexts = (peers: Peer[], expected: string[]) => { + for (const peer of peers) { + assert.deepEqual(paragraphTexts(peer), expected) + } +} + +const splitParagraph = (peer: Peer) => { + peer.editor.update((tx) => { + tx.nodes.split({ at: { path: [0, 0], offset: 'alph'.length } }) + }) +} + +const insertRemoteTextAtSplitPoint = (peer: Peer) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alph'.length } }) + }) +} + +const appendRemoteText = (peer: Peer) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alphabeta'.length } }) + }) +} + +describe('@slate/yjs split_node collaboration contract', () => { + it('applies local offline public split without a root snapshot fallback', () => { + const peer = createPeer('b') + const leftText = yjsNodeAt(peer, [0, 0]) + + yjsUpdate(peer, (yjs) => yjs.disconnect()) + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + splitParagraph(peer) + + assert.deepEqual(paragraphTexts(peer), ['alph', 'abeta']) + assert.equal(yjsNodeAt(peer, [0, 0]), leftText) + assert.deepEqual(yjsState(peer).trace(), [ + { mode: 'operation', operationType: 'split_node' }, + { mode: 'operation', operationType: 'split_node' }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote insert intent when an offline public split reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + splitParagraph(b) + insertRemoteTextAtSplitPoint(a) + syncConnected(peers) + + assert.deepEqual(paragraphTexts(a), ['alph!abeta']) + assert.deepEqual(paragraphTexts(b), ['alph', 'abeta']) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alph!', 'abeta']) + assertNoRootSnapshot(b) + }) + + it('recovers split convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + splitParagraph(b) + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['alph', 'abeta']) + }) + + it('undoes and redoes only the local split intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + splitParagraph(b) + insertRemoteTextAtSplitPoint(a) + syncConnected(peers) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['alph!', 'abeta']) + + yjsUpdate(b, (yjs) => yjs.undo()) + syncConnected(peers) + assertAllTexts(peers, ['alph!abeta']) + + yjsUpdate(b, (yjs) => yjs.redo()) + syncConnected(peers) + assertAllTexts(peers, ['alph!', 'abeta']) + assertNoRootSnapshot(b) + }) + + it('undoes an offline public split after a concurrent remote append', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + yjsUpdate(b, (yjs) => yjs.disconnect()) + splitParagraph(b) + appendRemoteText(a) + syncConnected(peers) + + yjsUpdate(b, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['alph!', 'abeta']) + + yjsUpdate(b, (yjs) => yjs.undo()) + syncConnected(peers) + assertAllTexts(peers, ['alph!abeta']) + + yjsUpdate(b, (yjs) => yjs.redo()) + syncConnected(peers) + assertAllTexts(peers, ['alph!', 'abeta']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/support/collaboration.ts b/packages/slate-yjs/test/support/collaboration.ts new file mode 100644 index 0000000000..0327fbea5d --- /dev/null +++ b/packages/slate-yjs/test/support/collaboration.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { createEditor, type Descendant } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { createYjsExtension } from '../../src' +import { getYjsNode } from '../../src/core/document' +import type { YjsAwarenessChange, YjsAwarenessLike } from '../../src/core/types' + +export type Peer = { + doc: Y.Doc + editor: ReturnType +} + +export class FakeAwareness implements YjsAwarenessLike { + readonly clientID: number + readonly doc: { clientID: number } + + private readonly listeners = new Set<(event: YjsAwarenessChange) => void>() + private localState: Record | null = null + private readonly states = new Map>() + + constructor(clientID: number) { + this.clientID = clientID + this.doc = { clientID } + } + + getLocalState() { + return this.localState + } + + getStates() { + return this.states + } + + off(event: 'change', handler: (event: YjsAwarenessChange) => void) { + if (event === 'change') { + this.listeners.delete(handler) + } + } + + on(event: 'change', handler: (event: YjsAwarenessChange) => void) { + if (event === 'change') { + this.listeners.add(handler) + } + } + + removeRemoteState(clientId: number) { + this.states.delete(clientId) + this.emit({ added: [], removed: [clientId], updated: [] }) + } + + setLocalStateField(field: string, value: unknown) { + this.localState = { + ...(this.localState ?? {}), + [field]: value, + } + this.states.set(this.clientID, this.localState) + this.emit({ added: [], removed: [], updated: [this.clientID] }) + } + + setRemoteState(clientId: number, state: Record) { + const added = this.states.has(clientId) ? [] : [clientId] + const updated = this.states.has(clientId) ? [clientId] : [] + + this.states.set(clientId, state) + this.emit({ added, removed: [], updated }) + } + + private emit(event: YjsAwarenessChange) { + for (const listener of this.listeners) { + listener(event) + } + } +} + +export const createYjsPeer = ({ + children, + awareness, + clientId, + numericClientId, + seedUpdate, +}: { + awareness?: YjsAwarenessLike + children: Descendant[] + clientId: string + numericClientId?: number + seedUpdate?: Uint8Array +}): Peer => { + const editor = createEditor() + + Editor.replace(editor, { + children, + marks: null, + selection: null, + }) + + const doc = new Y.Doc() + + if (numericClientId !== undefined) { + doc.clientID = numericClientId + } + + if (seedUpdate) { + Y.applyUpdate(doc, seedUpdate) + } + + editor.extend( + createYjsExtension({ awareness, clientId, doc, rootName: 'slate' }) + ) + + return { doc, editor } +} + +export const createSeededYjsPeers = ({ + children, + clientIds, + numericClientIds, +}: { + children: Descendant[] + clientIds: string[] + numericClientIds?: Record +}) => { + const [firstClientId, ...remainingClientIds] = clientIds + + if (!firstClientId) { + return [] + } + + const firstPeer = createYjsPeer({ + children, + clientId: firstClientId, + numericClientId: numericClientIds?.[firstClientId], + }) + const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) + + return [ + firstPeer, + ...remainingClientIds.map((clientId) => + createYjsPeer({ + children, + clientId, + numericClientId: numericClientIds?.[clientId], + seedUpdate, + }) + ), + ] +} + +export const getParagraphTexts = (peer: Peer) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +export const getYjsNodeAt = ( + peer: Peer, + path: number[] +): Y.XmlElement | Y.XmlText => { + let current: Y.XmlElement | Y.XmlText = getYjsState(peer).root() + + for (const index of path) { + if (current instanceof Y.XmlText) { + throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) + } + + const child = current + .toArray() + .filter( + (value): value is Y.XmlElement | Y.XmlText => + value instanceof Y.XmlElement || value instanceof Y.XmlText + )[index] + + if (!child) { + throw new Error(`No Yjs node at ${path.join('.')}`) + } + + current = child + } + + return current +} + +export const getVisibleYjsNodeAt = ( + peer: Peer, + path: number[] +): Y.XmlElement | Y.XmlText => getYjsNode(getYjsState(peer).root(), path) + +export const getYjsState = (peer: Peer) => + peer.editor.read((state) => (state as any).yjs) + +export const runYjsUpdate = (peer: Peer, fn: (tx: any) => void) => { + peer.editor.update((tx) => { + fn((tx as any).yjs) + }) +} + +export const syncConnectedPeers = (peers: Peer[]) => { + for (const source of peers) { + if (!getYjsState(source).connected()) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !getYjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } +} + +export const assertNoRootSnapshot = (peer: Peer) => { + assert.equal( + getYjsState(peer) + .trace() + .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), + false + ) +} + +export const assertPeerTexts = (peers: Peer[], expected: string[]) => { + for (const peer of peers) { + assert.deepEqual(getParagraphTexts(peer), expected) + } +} diff --git a/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts b/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts new file mode 100644 index 0000000000..6d55e2142e --- /dev/null +++ b/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import * as Y from 'yjs' + +import { + createYjsUndoManagerAdapter, + SUPPORTED_YJS_UNDO_MANAGER_VERSION, +} from '../src/core/undo-manager-adapter' + +describe('@slate/yjs Yjs UndoManager stack adapter contract', () => { + it('pins the Yjs stack contract to the audited version', () => { + assert.equal(SUPPORTED_YJS_UNDO_MANAGER_VERSION, '13.6.30') + }) + + it('stores metadata and moves audited stack items through the adapter', () => { + const doc = new Y.Doc() + const origin = {} + const root = doc.get('slate', Y.XmlElement) + const undoManager = new Y.UndoManager(root, { + trackedOrigins: new Set([origin]), + }) + const adapter = createYjsUndoManagerAdapter(undoManager) + + doc.transact(() => { + root.insert(0, [new Y.XmlText()]) + }, origin) + undoManager.stopCapturing() + + const undoItem = adapter.peekUndo() + + assert.ok(undoItem) + adapter.storeUndoMeta('contract', 42) + assert.equal(undoItem.meta.get('contract'), 42) + + adapter.moveUndoToRedo(undoItem) + assert.equal(adapter.peekUndo(), null) + assert.equal(adapter.peekRedo(), undoItem) + + adapter.moveRedoToUndo(undoItem) + assert.equal(adapter.peekUndo(), undoItem) + assert.equal(adapter.peekRedo(), null) + + undoManager.destroy() + }) + + it('keeps private stack property access isolated to the adapter', () => { + const controllerSource = readFileSync( + new URL('../src/core/controller.ts', import.meta.url), + 'utf8' + ) + const adapterSource = readFileSync( + new URL('../src/core/undo-manager-adapter.ts', import.meta.url), + 'utf8' + ) + + assert.equal(controllerSource.includes('undoStack'), false) + assert.equal(controllerSource.includes('redoStack'), false) + assert.equal(adapterSource.includes('undoStack'), true) + assert.equal(adapterSource.includes('redoStack'), true) + }) +}) diff --git a/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts b/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts new file mode 100644 index 0000000000..96598566a0 --- /dev/null +++ b/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant, defineEditorExtension } from 'slate' +import { Editor } from 'slate/internal' + +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha')] + +const createPeer = ( + clientId: keyof typeof clientIds, + seedUpdate?: Uint8Array +) => + createYjsPeer({ + children: initialValue(), + clientId, + numericClientId: clientIds[clientId], + seedUpdate, + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const topLevelTypes = (peer: ReturnType) => + Editor.getSnapshot(peer.editor).children.map((node) => + 'type' in node ? node.type : 'text' + ) + +const wrapFirstBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.wrap({ children: [], type: 'quote' }, { at: [0] }) + }) +} + +const unwrapFirstBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.unwrap({ at: [0] }) + }) +} + +const appendRemoteText = (peer: ReturnType) => { + const [type] = topLevelTypes(peer) + const textPath = type === 'quote' ? [0, 0, 0] : [0, 0] + + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: textPath, offset: 'alpha'.length } }) + }) +} + +const createWrappedPeer = (clientId: keyof typeof clientIds) => { + const peer = createPeer(clientId) + + wrapFirstBlock(peer) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + + return peer +} + +const createWrappedPeers = (ids: Array) => { + const peers = createPeers(ids) + + wrapFirstBlock(peers[0]!) + syncConnectedPeers(peers) + + for (const peer of peers) { + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + } + + return peers +} + +const collectUnwrapOperations = () => { + const peer = createWrappedPeer('b') + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'unwrap-operation-recorder', + setup() { + return { + onCommit({ commit }) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + unwrapFirstBlock(peer) + + return operations +} + +describe('@slate/yjs unwrapNodes collaboration contract', () => { + it('characterizes public unwrapNodes as move_node then remove_node', () => { + assert.deepEqual(collectUnwrapOperations(), ['move_node', 'remove_node']) + }) + + it('applies local offline public unwrap without replacing the original Yjs node', () => { + const peer = createWrappedPeer('b') + const original = getYjsNodeAt(peer, [1]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + unwrapFirstBlock(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alpha']) + assert.deepEqual(topLevelTypes(peer), ['paragraph']) + assert.equal(getYjsNodeAt(peer, [0]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { + fallback: 'virtual-unwrap-ref', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + { + fallback: 'virtual-unwrap-wrapper-remove', + mode: 'traceable-fallback', + operationType: 'remove_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline unwrap reconnects', () => { + const peers = createWrappedPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + unwrapFirstBlock(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + assert.deepEqual(getParagraphTexts(a), ['alpha!']) + assert.deepEqual(topLevelTypes(a), ['quote']) + assert.deepEqual(getParagraphTexts(b), ['alpha']) + assert.deepEqual(topLevelTypes(b), ['paragraph']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(a), ['paragraph']) + assert.deepEqual(topLevelTypes(b), ['paragraph']) + assertNoRootSnapshot(b) + }) + + it('recovers unwrap convergence through real Yjs updates after reconnect', () => { + const peers = createWrappedPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + unwrapFirstBlock(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha']) + assert.deepEqual(topLevelTypes(b), ['paragraph']) + }) + + it('undoes and redoes only the local unwrap intent after reconnect', () => { + const peers = createWrappedPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + unwrapFirstBlock(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(b), ['paragraph']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(b), ['quote']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(b), ['paragraph']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/test/wrap-nodes-contract.spec.ts b/packages/slate-yjs/test/wrap-nodes-contract.spec.ts new file mode 100644 index 0000000000..38935ad0f8 --- /dev/null +++ b/packages/slate-yjs/test/wrap-nodes-contract.spec.ts @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant, defineEditorExtension } from 'slate' +import { Editor } from 'slate/internal' + +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsNodeAt, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha')] + +const createPeer = ( + clientId: keyof typeof clientIds, + seedUpdate?: Uint8Array +) => + createYjsPeer({ + children: initialValue(), + clientId, + numericClientId: clientIds[clientId], + seedUpdate, + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const topLevelTypes = (peer: ReturnType) => + Editor.getSnapshot(peer.editor).children.map((node) => + 'type' in node ? node.type : 'text' + ) + +const wrapFirstBlock = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.nodes.wrap({ children: [], type: 'quote' }, { at: [0] }) + }) +} + +const appendRemoteText = (peer: ReturnType) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +const collectWrapOperations = () => { + const peer = createPeer('b') + const operations: string[] = [] + + peer.editor.extend( + defineEditorExtension({ + name: 'wrap-operation-recorder', + setup() { + return { + onCommit({ commit }) { + operations.push( + ...commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + wrapFirstBlock(peer) + + return operations +} + +describe('@slate/yjs wrapNodes collaboration contract', () => { + it('characterizes public wrapNodes as insert_node then move_node', () => { + assert.deepEqual(collectWrapOperations(), ['insert_node', 'move_node']) + }) + + it('applies local offline public wrap without replacing the original Yjs node', () => { + const peer = createPeer('b') + const original = getYjsNodeAt(peer, [0]) + + runYjsUpdate(peer, (yjs) => yjs.disconnect()) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + wrapFirstBlock(peer) + + assert.deepEqual(getParagraphTexts(peer), ['alpha']) + assert.deepEqual(topLevelTypes(peer), ['quote']) + assert.equal(getYjsNodeAt(peer, [1]), original) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'insert_node' }, + { + fallback: 'virtual-move-ref', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + assertNoRootSnapshot(peer) + }) + + it('preserves concurrent remote text when an offline wrap reconnects', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + wrapFirstBlock(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + assert.deepEqual(getParagraphTexts(a), ['alpha!']) + assert.deepEqual(topLevelTypes(a), ['paragraph']) + assert.deepEqual(getParagraphTexts(b), ['alpha']) + assert.deepEqual(topLevelTypes(b), ['quote']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(a), ['quote']) + assert.deepEqual(topLevelTypes(b), ['quote']) + assertNoRootSnapshot(b) + }) + + it('drops a preserved selection that no longer points to text after remote wrap import', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + a.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 'alpha'.length }, + focus: { path: [0, 0], offset: 'alpha'.length }, + }) + }) + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + wrapFirstBlock(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(a), ['quote']) + assert.equal(Editor.getSnapshot(a.editor).selection, null) + assertNoRootSnapshot(b) + }) + + it('recovers wrap convergence through real Yjs updates after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + wrapFirstBlock(b) + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha']) + assert.deepEqual(topLevelTypes(b), ['quote']) + }) + + it('undoes and redoes only the local wrap intent after reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + wrapFirstBlock(b) + appendRemoteText(a) + syncConnectedPeers(peers) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(b), ['quote']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(b), ['paragraph']) + + runYjsUpdate(b, (yjs) => yjs.redo()) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alpha!']) + assert.deepEqual(topLevelTypes(b), ['quote']) + assertNoRootSnapshot(b) + }) +}) diff --git a/packages/slate-yjs/tsconfig.build.json b/packages/slate-yjs/tsconfig.build.json new file mode 100644 index 0000000000..94ece82b4c --- /dev/null +++ b/packages/slate-yjs/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../config/typescript/tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "rootDir": "./src", + "outDir": "./lib", + "types": [] + } +} diff --git a/packages/slate-yjs/tsconfig.json b/packages/slate-yjs/tsconfig.json new file mode 100644 index 0000000000..5c93cc86cf --- /dev/null +++ b/packages/slate-yjs/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../config/typescript/tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "types": [] + } +} diff --git a/packages/slate-yjs/tsdown.config.mts b/packages/slate-yjs/tsdown.config.mts new file mode 100644 index 0000000000..16e590322f --- /dev/null +++ b/packages/slate-yjs/tsdown.config.mts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +const enableSourcemaps = !process.env.CI +const dts = { + bundle: true, + sourcemap: enableSourcemaps, +} + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'core/index': 'src/core/index.ts', + 'internal/index': 'src/internal/index.ts', + 'react/index': 'src/react/index.ts', + }, + format: ['esm'], + clean: true, + platform: 'neutral', + tsconfig: 'tsconfig.build.json', + sourcemap: enableSourcemaps, + dts, + outExtensions: () => ({ + js: '.js', + }), +}) diff --git a/packages/slate/src/core/public-state.ts b/packages/slate/src/core/public-state.ts index 4a084574f1..251347b79f 100644 --- a/packages/slate/src/core/public-state.ts +++ b/packages/slate/src/core/public-state.ts @@ -1449,6 +1449,9 @@ export const getOperationDirtiness = ( const hasReplaceFragmentOperation = operations.some( (op) => op.type === 'replace_fragment' ) + const hasStructuralTextOperation = operations.some( + operationChangesTextContent + ) const classes = reason === 'replace' || hasReplaceFragmentOperation ? (['replace'] as const) @@ -1464,7 +1467,9 @@ export const getOperationDirtiness = ( ) ? (['text'] as const) : operations.length > 0 - ? (['structural'] as const) + ? hasStructuralTextOperation + ? (['structural', 'text'] as const) + : (['structural'] as const) : statePatches.length > 0 ? (['state'] as const) : (['mark'] as const) @@ -3574,6 +3579,10 @@ const operationChangesTopLevelOrder = (operation: Operation): boolean => { } } +function operationChangesTextContent(operation: Operation): boolean { + return operation.type === 'split_node' && operation.path.length > 1 +} + const getOperationScopePaths = (operations: readonly Operation[]): Path[] => operations.flatMap((operation) => { if (!('path' in operation) || !Array.isArray(operation.path)) { @@ -3592,6 +3601,15 @@ const getTextOperationPaths = (operations: readonly Operation[]): Path[] => : [] ) +const getStructuralTextOperationPaths = ( + operations: readonly Operation[] +): Path[] => + operations.flatMap((operation) => + operation.type === 'split_node' && operation.path.length > 1 + ? [operation.path] + : [] + ) + const getTextElementPaths = (operations: readonly Operation[]): Path[] => uniqPaths( getTextOperationPaths(operations).flatMap((path) => @@ -3662,6 +3680,14 @@ const buildCommitRuntimeDirtiness = ({ } } + const structuralTextRuntimeIds = + changeClass === 'structural' + ? getRuntimeIdsForPaths( + getStructuralTextOperationPaths(operations), + previousIndex, + nextIndex + ) + : [] const dirtyTextRuntimeIds = changeClass === 'text' ? getRuntimeIdsForPaths( @@ -3669,7 +3695,7 @@ const buildCommitRuntimeDirtiness = ({ previousIndex, nextIndex ) - : [] + : structuralTextRuntimeIds const dirtyElementRuntimeIds = changeClass === 'structural' ? null @@ -3686,7 +3712,9 @@ const buildCommitRuntimeDirtiness = ({ affectedProjectionRuntimeIds: decorationImpactRuntimeIds, affectedSelectionRuntimeIds: selectionImpactRuntimeIds, affectedTextRuntimeIds: - changeClass === 'text' ? dirtyTextRuntimeIds : ([] as RuntimeId[]), + changeClass === 'text' || structuralTextRuntimeIds.length > 0 + ? dirtyTextRuntimeIds + : ([] as RuntimeId[]), dirtyElementRuntimeIds, dirtyTextRuntimeIds, dirtyTopLevelRanges: getTopLevelRanges(scopePaths), @@ -3699,7 +3727,9 @@ const buildCommitRuntimeDirtiness = ({ structuralDirtyRuntimeIds: changeClass === 'structural' ? null : ([] as RuntimeId[]), textDirtyRuntimeIds: - changeClass === 'text' ? dirtyTextRuntimeIds : ([] as RuntimeId[]), + changeClass === 'text' || structuralTextRuntimeIds.length > 0 + ? dirtyTextRuntimeIds + : ([] as RuntimeId[]), topLevelOrderChanged, } } @@ -3882,6 +3912,9 @@ export const buildSnapshotChange = ({ const hasReplaceFragmentOperation = operations.some( (op) => op.type === 'replace_fragment' ) + const hasStructuralTextOperation = operations.some( + operationChangesTextContent + ) const classes = reason === 'replace' || hasReplaceFragmentOperation ? (['replace'] as const) @@ -3897,7 +3930,9 @@ export const buildSnapshotChange = ({ ) ? (['text'] as const) : operations.length > 0 - ? (['structural'] as const) + ? hasStructuralTextOperation + ? (['structural', 'text'] as const) + : (['structural'] as const) : statePatches.length > 0 ? (['state'] as const) : (['mark'] as const) diff --git a/packages/slate/test/snapshot-contract.ts b/packages/slate/test/snapshot-contract.ts index 1b0b94a2cb..24c320df1b 100644 --- a/packages/slate/test/snapshot-contract.ts +++ b/packages/slate/test/snapshot-contract.ts @@ -2657,11 +2657,16 @@ it('supports split_node on a text path and keeps the original id on the left bra }) const after = Editor.getSnapshot(editor) + const commit = Editor.getLastCommit(editor) assert.equal(after.children[0].children[0].text, 'alp') assert.equal(after.children[0].children[1].text, 'ha') assert.equal(after.index.pathToId['0.0'], leftId) assert.notEqual(after.index.pathToId['0.1'], leftId) + assert.equal(commit?.structureChanged, true) + assert.equal(commit?.textChanged, true) + assert.deepEqual(commit?.dirtyTextRuntimeIds, [leftId]) + assert.deepEqual(commit?.textDirtyRuntimeIds, [leftId]) assert.deepEqual(after.selection, { anchor: { path: [0, 1], offset: 0 }, focus: { path: [0, 1], offset: 0 }, diff --git a/playwright/integration/examples/synced-blocks.test.ts b/playwright/integration/examples/synced-blocks.test.ts index cca989a48e..2a7139d56a 100644 --- a/playwright/integration/examples/synced-blocks.test.ts +++ b/playwright/integration/examples/synced-blocks.test.ts @@ -807,9 +807,9 @@ test.describe('synced blocks example', () => { segments: { backward: true }, }) await expect.poll(() => getRenderedViewSelectionText(page)).toContain('p1') - await expect.poll(() => getNativeSelectionText(page)).not.toBe( - 'Shared mission' - ) + await expect + .poll(() => getNativeSelectionText(page)) + .not.toBe('Shared mission') }) test('mouse selection across synced blocks becomes the same visible-order selection as sibling blocks', async ({ diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts new file mode 100644 index 0000000000..20689620e8 --- /dev/null +++ b/playwright/integration/examples/yjs-collaboration.test.ts @@ -0,0 +1,1518 @@ +import { expect, type Page, test } from '@playwright/test' + +import { openExample } from 'slate-browser/playwright' + +type PeerId = 'a' | 'b' | 'c' | 'd' + +const byTestId = (page: Page, id: string) => + page.locator(`[data-test-id="${id}"]`) + +const peerSurface = (page: Page, peer: PeerId) => + page.locator(`#yjs-peer-${peer}-editor-surface`) + +const peerTextbox = (page: Page, peer: PeerId) => + peerSurface(page, peer).locator('[role="textbox"]') + +const selectFirstText = async (page: Page, peer: PeerId, length: number) => { + await selectPeerTextRange(page, peer, 0, 0, length) +} + +const getPeerParagraphTexts = (page: Page, peer: PeerId) => + peerTextbox(page, peer).evaluate((textbox) => + [...textbox.querySelectorAll('p')].map((paragraph) => { + const clone = paragraph.cloneNode(true) as HTMLElement + + clone + .querySelectorAll('[data-slate-placeholder="true"]') + .forEach((placeholder) => { + placeholder.remove() + }) + + return (clone.textContent ?? '').replaceAll('\uFEFF', '') + }) + ) + +const getPeerLayoutProof = (page: Page, peer: PeerId) => + peerTextbox(page, peer).evaluate((textbox) => { + const editorRect = textbox.getBoundingClientRect() + + return { + editorHeight: Math.round(editorRect.height), + paragraphs: [...textbox.querySelectorAll('p')].map((paragraph) => { + const rect = paragraph.getBoundingClientRect() + + return { + height: Math.round(rect.height), + text: paragraph.textContent ?? '', + } + }), + } + }) + +const getHistoryShortcuts = (page: Page) => + page.evaluate(() => + /Mac|iPhone|iPad/.test(navigator.platform) + ? { redo: 'Meta+Shift+Z', undo: 'Meta+Z' } + : { redo: 'Control+Shift+Z', undo: 'Control+Z' } + ) + +const replacePeerText = async ( + page: Page, + peer: PeerId, + paragraphs: string[] +) => { + await peerTextbox(page, peer).evaluate((textbox, nextParagraphs) => { + const handle = ( + textbox as HTMLElement & { + __slateBrowserHandle?: { + applyOperations: ( + operations: readonly Record[], + options?: Record + ) => void + } + } + ).__slateBrowserHandle + + if (!handle?.applyOperations) { + throw new Error('Peer editor does not expose Slate browser handle setup') + } + + const toParagraph = (text: string) => ({ + children: [{ text }], + type: 'paragraph', + }) + const currentParagraphs = [...textbox.querySelectorAll('p')].map( + (paragraph) => + (paragraph.textContent ?? '') + .replaceAll('\uFEFF', '') + .replaceAll('\n', '') + ) + const operations: Record[] = [] + const [currentFirst = ''] = currentParagraphs + const [nextFirst = ''] = nextParagraphs + + for (let index = currentParagraphs.length - 1; index > 0; index--) { + operations.push({ + node: toParagraph(currentParagraphs[index] ?? ''), + path: [index], + root: 'main', + type: 'remove_node', + }) + } + + if (currentFirst.length > 0) { + operations.push({ + offset: 0, + path: [0, 0], + root: 'main', + text: currentFirst, + type: 'remove_text', + }) + } + + if (nextFirst.length > 0) { + operations.push({ + offset: 0, + path: [0, 0], + root: 'main', + text: nextFirst, + type: 'insert_text', + }) + } + + nextParagraphs.slice(1).forEach((paragraph, index) => { + operations.push({ + node: toParagraph(paragraph), + path: [index + 1], + root: 'main', + type: 'insert_node', + }) + }) + + handle.applyOperations(operations) + }, paragraphs) +} + +const placePeerCaret = async ( + page: Page, + peer: PeerId, + paragraphIndex: number, + offset: number +) => { + const position = await page.evaluate( + ({ offset, paragraphIndex, peer }) => { + const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) + const textbox = root?.querySelector('[role="textbox"]') + const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] + const textNode = paragraph + ? document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT).nextNode() + : null + + if (!textbox || !textNode) { + throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) + } + + const textboxRect = textbox.getBoundingClientRect() + const paragraphRect = paragraph!.getBoundingClientRect() + const toTextboxPoint = (x: number, y: number) => ({ + x: Math.max(1, Math.min(textboxRect.width - 1, x - textboxRect.left)), + y: Math.max(1, Math.min(textboxRect.height - 1, y - textboxRect.top)), + }) + + if (offset <= 0) { + return toTextboxPoint( + paragraphRect.left + 2, + paragraphRect.top + paragraphRect.height / 2 + ) + } + + const range = document.createRange() + const boundedOffset = Math.min(offset, textNode.textContent?.length ?? 0) + + range.setStart(textNode, 0) + range.setEnd(textNode, boundedOffset) + + const rect = range.getBoundingClientRect() + + return toTextboxPoint( + Math.max(paragraphRect.left + 2, rect.right + 1), + rect.top + rect.height / 2 + ) + }, + { offset, paragraphIndex, peer } + ) + + await peerTextbox(page, peer).click({ position }) +} + +const selectPeerTextRange = async ( + page: Page, + peer: PeerId, + paragraphIndex: number, + anchorOffset: number, + focusOffset: number +) => { + if (anchorOffset === focusOffset) { + await placePeerCaret(page, peer, paragraphIndex, anchorOffset) + return + } + + await peerTextbox(page, peer).click() + await page.evaluate( + ({ anchorOffset, focusOffset, paragraphIndex, peer }) => { + const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) + const textbox = root?.querySelector('[role="textbox"]') + const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] + + if (!textbox || !paragraph) { + throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) + } + + const findPoint = (offset: number) => { + const walker = document.createTreeWalker( + paragraph, + NodeFilter.SHOW_TEXT + ) + let seen = 0 + let textNode = walker.nextNode() + + while (textNode) { + const text = textNode.textContent ?? '' + const visibleText = text.replaceAll('\uFEFF', '') + + if (visibleText.length === 0) { + textNode = walker.nextNode() + continue + } + + const nextSeen = seen + visibleText.length + + if (offset <= nextSeen) { + return { + node: textNode, + offset: Math.max(0, offset - seen), + } + } + + seen = nextSeen + textNode = walker.nextNode() + } + + return { + node: paragraph, + offset: paragraph.childNodes.length, + } + } + + const anchor = findPoint(anchorOffset) + const focus = findPoint(focusOffset) + const range = document.createRange() + + range.setStart(anchor.node, anchor.offset) + range.setEnd(focus.node, focus.offset) + + const selection = document.getSelection() + + selection?.removeAllRanges() + selection?.addRange(range) + textbox.focus() + + const handle = ( + textbox as HTMLElement & { + __slateBrowserHandle?: { + selectRange: (range: { + anchor: { offset: number; path: number[] } + focus: { offset: number; path: number[] } + }) => void + } + } + ).__slateBrowserHandle + + handle?.selectRange?.({ + anchor: { path: [paragraphIndex, 0], offset: anchorOffset }, + focus: { path: [paragraphIndex, 0], offset: focusOffset }, + }) + + document.dispatchEvent(new Event('selectionchange')) + }, + { anchorOffset, focusOffset, paragraphIndex, peer } + ) +} + +const selectPeerSlateRange = async ( + page: Page, + peer: PeerId, + range: { + anchor: { offset: number; path: number[] } + focus: { offset: number; path: number[] } + } +) => { + await peerTextbox(page, peer).click() + await page.evaluate( + ({ peer, range }) => { + const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) + const textbox = root?.querySelector('[role="textbox"]') + + if (!textbox) { + throw new Error(`Peer ${peer} textbox not found`) + } + + const findPoint = (path: number[], offset: number) => { + const [paragraphIndex] = path + const paragraph = textbox.querySelectorAll('p')[paragraphIndex!] + + if (!paragraph) { + throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) + } + + const walker = document.createTreeWalker( + paragraph, + NodeFilter.SHOW_TEXT + ) + let seen = 0 + let textNode = walker.nextNode() + + while (textNode) { + const text = textNode.textContent ?? '' + const visibleText = text.replaceAll('\uFEFF', '') + + if (visibleText.length === 0) { + textNode = walker.nextNode() + continue + } + + const nextSeen = seen + visibleText.length + + if (offset <= nextSeen) { + return { + node: textNode, + offset: Math.max(0, offset - seen), + } + } + + seen = nextSeen + textNode = walker.nextNode() + } + + return { + node: paragraph, + offset: paragraph.childNodes.length, + } + } + + const anchor = findPoint(range.anchor.path, range.anchor.offset) + const focus = findPoint(range.focus.path, range.focus.offset) + const domRange = document.createRange() + + domRange.setStart(anchor.node, anchor.offset) + domRange.setEnd(focus.node, focus.offset) + + const selection = document.getSelection() + + selection?.removeAllRanges() + selection?.addRange(domRange) + textbox.focus() + + const handle = ( + textbox as HTMLElement & { + __slateBrowserHandle?: { + selectRange: (range: { + anchor: { offset: number; path: number[] } + focus: { offset: number; path: number[] } + }) => void + } + } + ).__slateBrowserHandle + + handle?.selectRange?.(range) + document.dispatchEvent(new Event('selectionchange')) + }, + { peer, range } + ) +} + +const selectPeerParagraphNode = async ( + page: Page, + peer: PeerId, + paragraphIndex: number +) => { + await page.evaluate( + ({ paragraphIndex, peer }) => { + const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) + const textbox = root?.querySelector('[role="textbox"]') + const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] + + if (!textbox || !paragraph) { + throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) + } + + const range = document.createRange() + + range.selectNode(paragraph) + + const selection = document.getSelection() + + selection?.removeAllRanges() + selection?.addRange(range) + textbox.dataset.yjsSelectedParagraphNode = String(paragraphIndex) + textbox.focus() + document.dispatchEvent(new Event('selectionchange')) + }, + { paragraphIndex, peer } + ) +} + +test.describe('yjs collaboration example', () => { + test('renders the full operation control matrix', async ({ page }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + const controls = [ + 'append', + 'replace', + 'remove-node', + 'split-node', + 'merge-node', + 'move-down', + 'set-node', + 'unset-node', + 'wrap-node', + 'unwrap', + 'lift', + 'insert-fragment', + 'delete-fragment', + 'delete-backward', + 'insert-text', + 'move', + ] + + for (const control of controls) { + await expect(byTestId(page, `yjs-peer-a-${control}`)).toBeVisible() + } + }) + + test('syncs marks applied from one editor to all connected editors', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-select').click() + await byTestId(page, 'yjs-peer-a-mark-bold').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect(peerSurface(page, peer).locator('strong')).toContainText( + 'Hello' + ) + } + }) + + test('projects awareness selection to the remote peer', async ({ page }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-select').click() + + await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( + '101:0.0:0-0.0:5' + ) + }) + + test('clears remote cursor presence while a peer is disconnected', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-select').click() + + await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( + '101:0.0:0-0.0:5' + ) + + await byTestId(page, 'yjs-peer-a-disconnect').click() + + await expect(byTestId(page, 'yjs-peer-b-cursors')).toHaveText('remote:none') + + await byTestId(page, 'yjs-peer-a-connect').click() + + await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( + '101:0.0:0-0.0:5' + ) + }) + + test('keeps peers converged through append, undo, and redo', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-append').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'b')).toContainText('Ada') + + await byTestId(page, 'yjs-peer-a-undo').click() + + await expect(peerSurface(page, 'a')).not.toContainText('Ada') + await expect(peerSurface(page, 'b')).not.toContainText('Ada') + + await byTestId(page, 'yjs-peer-a-redo').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'b')).toContainText('Ada') + }) + + test('shares user history between keyboard undo and redo', async ({ + page, + }) => { + await page.goto( + `${process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3100'}/examples/yjs-collaboration` + ) + await peerSurface(page, 'a').locator('[role="textbox"]').waitFor() + const typedText = ' typed-history' + + await page.evaluate(() => { + const root = document.querySelector('#yjs-peer-a-editor-surface') + const textbox = root?.querySelector('[role="textbox"]') + + if (!textbox?.firstChild) { + throw new Error('Peer A textbox text node not found') + } + + const textNode = document + .createTreeWalker(textbox, NodeFilter.SHOW_TEXT) + .nextNode() + + if (!textNode) { + throw new Error('Peer A textbox text node not found') + } + + const range = document.createRange() + range.setStart(textNode, textNode.textContent?.length ?? 0) + range.setEnd(textNode, textNode.textContent?.length ?? 0) + + const selection = document.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + textbox.focus() + document.dispatchEvent(new Event('selectionchange')) + }) + await page.keyboard.type(typedText) + + await expect(peerSurface(page, 'a')).toContainText(typedText) + await expect(peerSurface(page, 'b')).toContainText(typedText) + + await peerSurface(page, 'a').locator('[role="textbox"]').focus() + const { redo, undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + await expect(peerSurface(page, 'a')).not.toContainText(typedText) + await expect(peerSurface(page, 'b')).not.toContainText(typedText) + + await page.keyboard.press(redo) + + await expect(peerSurface(page, 'a')).toContainText(typedText) + await expect(peerSurface(page, 'b')).toContainText(typedText) + }) + + test('keeps peers usable after selecting all and deleting', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + const editorA = peerTextbox(page, 'a') + + await editorA.click() + await page.keyboard.press('ControlOrMeta+A') + await page.keyboard.press('Backspace') + + await expect(editorA).toBeFocused() + await expect(editorA.locator('p')).toHaveCount(1) + await expect(peerTextbox(page, 'b').locator('p')).toHaveCount(1) + await expect + .poll(() => + editorA.evaluate((textbox) => { + const paragraph = textbox.querySelector('p') + const placeholder = textbox.querySelector( + '[data-slate-placeholder="true"]' + ) + + if (!paragraph || !placeholder) { + return null + } + + const paragraphRect = paragraph.getBoundingClientRect() + const placeholderRect = placeholder.getBoundingClientRect() + + return { + leftDelta: Math.abs(placeholderRect.left - paragraphRect.left), + topDelta: Math.abs(placeholderRect.top - paragraphRect.top), + widthDelta: Math.abs(placeholderRect.width - paragraphRect.width), + } + }) + ) + .toEqual({ leftDelta: 0, topDelta: 0, widthDelta: 0 }) + + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['Hello world!']) + } + + expect(pageErrors).toEqual([]) + }) + + test('keeps single-line select-all deletion focused for continued typing', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + const editorA = peerTextbox(page, 'a') + + await editorA.click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['Hello world!']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Hello world!']) + + await page.keyboard.press('ControlOrMeta+A') + await expect(editorA).toBeFocused() + await expect + .poll(() => page.evaluate(() => getSelection()?.toString())) + .toBe('Hello world!') + + await page.keyboard.press('Backspace') + + await expect(editorA).toBeFocused() + await page.keyboard.type('2') + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect.poll(() => getPeerParagraphTexts(page, peer)).toEqual(['2']) + } + + expect(pageErrors).toEqual([]) + }) + + test('clears stale local undo after a remote replace deletes that edit', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-append').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + + await byTestId(page, 'yjs-peer-b-replace').click() + + await expect(peerSurface(page, 'a')).toContainText( + 'Lin canonical snapshot.' + ) + await expect(peerSurface(page, 'b')).toContainText( + 'Lin canonical snapshot.' + ) + + await peerSurface(page, 'a').locator('[role="textbox"]').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + await expect(peerSurface(page, 'a')).toContainText( + 'Lin canonical snapshot.' + ) + await expect(peerSurface(page, 'b')).toContainText( + 'Lin canonical snapshot.' + ) + expect(pageErrors).toEqual([]) + }) + + test('clears stale local undo after a remote replace deletes an offline mark', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await selectFirstText(page, 'b', 'Hello'.length) + await byTestId(page, 'yjs-peer-b-mark-bold').click() + + await expect(peerSurface(page, 'b').locator('strong')).toContainText( + 'Hello' + ) + await expect(byTestId(page, 'yjs-peer-b-undo')).toBeEnabled() + + await byTestId(page, 'yjs-peer-a-replace').click() + + await expect(peerSurface(page, 'a')).toContainText( + 'Ada canonical snapshot.' + ) + await expect(peerSurface(page, 'b')).toContainText('Hello world!') + + await byTestId(page, 'yjs-peer-b-connect').click() + + await expect(peerSurface(page, 'b')).toContainText( + 'Ada canonical snapshot.' + ) + await expect(byTestId(page, 'yjs-peer-b-undo')).toBeDisabled() + + await peerSurface(page, 'b').locator('[role="textbox"]').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + await expect(peerSurface(page, 'b')).toContainText( + 'Ada canonical snapshot.' + ) + expect(pageErrors).toEqual([]) + }) + + test('exports replace snapshots to connected peers', async ({ page }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-replace').click() + + await expect(peerSurface(page, 'a')).toContainText( + 'Lin canonical snapshot.' + ) + await expect(peerSurface(page, 'b')).toContainText( + 'Lin canonical snapshot.' + ) + await expect(peerSurface(page, 'a')).not.toContainText('Hello world!') + }) + + test('disconnect and connect recover a stale peer', async ({ page }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + + await byTestId(page, 'yjs-peer-a-append').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'b')).not.toContainText('Ada') + + await byTestId(page, 'yjs-peer-b-reconcile').click() + + await expect(peerSurface(page, 'b')).not.toContainText('Ada') + + await byTestId(page, 'yjs-peer-b-connect').click() + await expect(peerSurface(page, 'b')).toContainText('Ada') + }) + + test('merges local disconnected appends when the peer reconnects', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + + await byTestId(page, 'yjs-peer-b-append').click() + + await expect(peerSurface(page, 'b')).toContainText('Lin') + await expect(peerSurface(page, 'a')).not.toContainText('Lin') + + await byTestId(page, 'yjs-peer-a-append').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'b')).not.toContainText('Ada') + + await byTestId(page, 'yjs-peer-b-connect').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'a')).toContainText('Lin') + await expect(peerSurface(page, 'b')).toContainText('Ada') + await expect(peerSurface(page, 'b')).toContainText('Lin') + }) + + test('keeps disconnected local edit undoable after reconnect', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-append').click() + await byTestId(page, 'yjs-peer-a-append').click() + await byTestId(page, 'yjs-peer-b-connect').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'a')).toContainText('Lin') + await expect(peerSurface(page, 'b')).toContainText('Ada') + await expect(peerSurface(page, 'b')).toContainText('Lin') + + await byTestId(page, 'yjs-peer-b-undo').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'a')).not.toContainText('Lin') + await expect(peerSurface(page, 'b')).toContainText('Ada') + await expect(peerSurface(page, 'b')).not.toContainText('Lin') + }) + + test('preserves remote appends when an offline replace is undone before reconnect', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-replace').click() + + await expect(peerSurface(page, 'b')).toContainText( + 'Lin canonical snapshot.' + ) + + await byTestId(page, 'yjs-peer-b-undo').click() + + await expect(peerSurface(page, 'b')).toContainText('Hello world!') + + await byTestId(page, 'yjs-peer-a-append').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'b')).not.toContainText('Ada') + + await byTestId(page, 'yjs-peer-b-connect').click() + + await expect(peerSurface(page, 'a')).toContainText('Ada') + await expect(peerSurface(page, 'b')).toContainText('Ada') + }) + + test('preserves concurrent text when an offline Backspace merge reconnects', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await placePeerCaret(page, 'b', 1, 0) + await page.keyboard.press('Backspace') + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alphabeta']) + + await selectPeerTextRange(page, 'a', 0, 'alpha'.length, 'alpha'.length) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha!', 'beta']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!beta']) + } + }) + + test('undoes offline split paragraph insertion after reconnect', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha']) + await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['alpha']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await placePeerCaret(page, 'b', 0, 'alpha'.length) + await page.keyboard.press('Enter') + await page.keyboard.type('beta') + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await selectPeerTextRange(page, 'a', 0, 'alpha'.length, 'alpha'.length) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!', 'beta']) + } + + await peerTextbox(page, 'b').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!']) + } + }) + + test('keeps public split button undo converged after reconnect', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alphabeta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alphabeta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-split-node').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alph', 'abeta']) + + await byTestId(page, 'yjs-peer-a-insert-text').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alphabeta!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alph!', 'abeta']) + } + + await byTestId(page, 'yjs-peer-b-undo').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alph!abeta']) + } + }) + + test('preserves concurrent text when an offline wrap button reconnects', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-wrap-node').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await byTestId(page, 'yjs-peer-a-insert-text').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha!', 'beta']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!', 'beta']) + } + }) + + test('preserves concurrent text when an offline insert fragment reconnects', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha']) + await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['alpha']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-insert-fragment').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alphaLin fragment']) + + await byTestId(page, 'yjs-peer-a-append').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha Ada']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha AdaLin fragment']) + } + }) + + test('undoes offline Backspace merge after a concurrent text edit reconnects', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await placePeerCaret(page, 'b', 1, 0) + await page.keyboard.press('Backspace') + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alphabeta']) + + await selectPeerTextRange(page, 'a', 0, 'alpha'.length, 'alpha'.length) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha!', 'beta']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!beta']) + } + + await peerTextbox(page, 'b').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!', 'beta']) + } + }) + + test('preserves absorbed-block text when an offline expanded deletion reconnects', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta', 'gamma']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta', 'gamma']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await selectPeerSlateRange(page, 'b', { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [2, 0], offset: 2 }, + }) + await page.keyboard.press('Delete') + await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['almma']) + + await selectPeerTextRange(page, 'a', 2, 'gamma'.length, 'gamma'.length) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha', 'beta', 'gamma!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['almma!']) + } + + await peerTextbox(page, 'b').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha', 'beta', 'gamma!']) + } + }) + + test('preserves concurrent text when an offline block removal reconnects', async ({ + page, + }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await selectPeerParagraphNode(page, 'b', 1) + await page.keyboard.press('Backspace') + await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['alpha']) + + await editor.selection.selectDOM({ + anchor: { path: [0, 0], offset: 'alpha'.length }, + focus: { path: [0, 0], offset: 'alpha'.length }, + }) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha!', 'beta']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha!']) + } + }) + + test('preserves concurrent text inside a block whose text is removed offline', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await selectPeerTextRange(page, 'b', 1, 0, 'beta'.length) + await page.keyboard.press('Backspace') + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', '']) + + await selectPeerTextRange(page, 'a', 1, 'beta'.length, 'beta'.length) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha', 'beta!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha', '!']) + } + + await peerTextbox(page, 'b').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha', 'beta!']) + } + }) + + test('undoes an offline selection replacement without dropping concurrent text', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha beta']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha beta']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await selectPeerTextRange(page, 'b', 0, 0, 'alpha'.length) + await page.keyboard.type('ALPHA') + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['ALPHA beta']) + + await selectPeerTextRange( + page, + 'a', + 0, + 'alpha beta'.length, + 'alpha beta'.length + ) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha beta!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['ALPHA beta!']) + } + + await peerTextbox(page, 'b').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha beta!']) + } + }) + + test('preserves concurrent sibling text when an offline move reconnects', async ({ + page, + }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta', 'gamma']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta', 'gamma']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-move').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['beta', 'alpha', 'gamma']) + + await editor.selection.selectDOM({ + anchor: { path: [2, 0], offset: 'gamma'.length }, + focus: { path: [2, 0], offset: 'gamma'.length }, + }) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha', 'beta', 'gamma!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['beta', 'alpha', 'gamma!']) + } + }) + + test('keeps offline move undo and redo converged after reconnect', async ({ + page, + }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['alpha', 'beta', 'gamma']) + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['alpha', 'beta', 'gamma']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-move').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['beta', 'alpha', 'gamma']) + + await editor.selection.selectDOM({ + anchor: { path: [2, 0], offset: 'gamma'.length }, + focus: { path: [2, 0], offset: 'gamma'.length }, + }) + await page.keyboard.type('!') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['alpha', 'beta', 'gamma!']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['beta', 'alpha', 'gamma!']) + } + + await peerTextbox(page, 'b').focus() + const { redo, undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['alpha', 'beta', 'gamma!']) + } + + await page.keyboard.press(redo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['beta', 'alpha', 'gamma!']) + } + }) + + test('keeps peer DOM layout synchronized after rapid history button replay', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await placePeerCaret(page, 'a', 0, 'Hello world!'.length) + + for (let index = 0; index < 3; index++) { + await page.keyboard.press('Enter') + } + + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Hello world!', '', '', '']) + + for (let index = 0; index < 3; index++) { + await byTestId(page, 'yjs-peer-a-undo').click() + } + for (let index = 0; index < 3; index++) { + await byTestId(page, 'yjs-peer-a-redo').click() + } + + await expect + .poll(async () => { + const proofs = await Promise.all( + (['a', 'b', 'c', 'd'] as const).map((peer) => + getPeerLayoutProof(page, peer) + ) + ) + const heights = proofs.map((proof) => proof.editorHeight) + const heightSpread = Math.max(...heights) - Math.min(...heights) + const paragraphHeights = proofs.flatMap((proof) => + proof.paragraphs.map((paragraph) => paragraph.height) + ) + const paragraphHeightSpread = + Math.max(...paragraphHeights) - Math.min(...paragraphHeights) + const paragraphTexts = proofs.map((proof) => + proof.paragraphs.map((paragraph) => paragraph.text) + ) + + return { + heightSpread, + paragraphHeightSpread, + paragraphTexts, + } + }) + .toEqual({ + heightSpread: 0, + paragraphHeightSpread: 0, + paragraphTexts: [ + ['Hello world!', '', '', ''], + ['Hello world!', '', '', ''], + ['Hello world!', '', '', ''], + ['Hello world!', '', '', ''], + ], + }) + }) + + test('merges offline mark, text replacement, and paragraph insertion edits', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + for (const peer of ['b', 'c', 'd'] as const) { + await byTestId(page, `yjs-peer-${peer}-disconnect`).click() + } + + await selectFirstText(page, 'b', 'Hello'.length) + await byTestId(page, 'yjs-peer-b-mark-bold').click() + await expect(peerSurface(page, 'b').locator('strong')).toContainText( + 'Hello' + ) + + await selectFirstText(page, 'c', 'Hello'.length) + await page.keyboard.type('Hi') + + await placePeerCaret(page, 'd', 0, 'Hello world!'.length) + await page.keyboard.press('Enter') + await page.keyboard.type('Test') + + await expect(peerSurface(page, 'c')).toContainText('Hi world!') + await expect(peerSurface(page, 'd')).toContainText('Test') + + for (const peer of ['b', 'c', 'd'] as const) { + await byTestId(page, `yjs-peer-${peer}-connect`).click() + } + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect(peerSurface(page, peer)).toContainText('Hi world!') + await expect(peerSurface(page, peer)).toContainText('Test') + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual(['Hi world!', 'Test']) + } + }) + + test('keeps expanded browser selection deletion synchronized', async ({ + page, + }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await editor.selection.selectDOM({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 'Hello'.length }, + }) + await editor.assert.selection({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 'Hello'.length }, + }) + + await page.keyboard.press('Delete') + + await expect(peerSurface(page, 'a')).not.toContainText('Hello') + await expect(peerSurface(page, 'b')).not.toContainText('Hello') + await expect + .poll(() => editor.selection.get()) + .toEqual({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }) + }) +}) diff --git a/site/constants/examples.ts b/site/constants/examples.ts index 53cf4b6dc1..71b55ec0db 100644 --- a/site/constants/examples.ts +++ b/site/constants/examples.ts @@ -42,6 +42,7 @@ export const EXAMPLE_NAMES_AND_PATHS = [ ['Styling', 'styling'], ['Synced Blocks', 'synced-blocks', { badge: 'new' }], ['Tables', 'tables'], + ['Yjs Collaboration', 'yjs-collaboration', { badge: 'new' }], ] as const satisfies readonly ExampleDefinition[] export const HIDDEN_EXAMPLES = [ diff --git a/site/examples/ts/pagination.tsx b/site/examples/ts/pagination.tsx index 496b983573..96ab410a85 100644 --- a/site/examples/ts/pagination.tsx +++ b/site/examples/ts/pagination.tsx @@ -972,15 +972,20 @@ const PaginationSurface = ({ return } - tx.value.replace({ - children: [ - ...root.filter((node) => !isRichMarkdownStressBlock(node)), - ...Array.from({ length: nextStressPages }, (_, index) => - createRichMarkdownStressSection(index) - ).flat(), - ], - selection: null, - }) + tx.selection.clear() + + for (let index = stressIndexes.length - 1; index >= 0; index--) { + tx.nodes.remove({ at: [stressIndexes[index]!] }) + } + + const stressBlocks = Array.from( + { length: nextStressPages }, + (_, index) => createRichMarkdownStressSection(index) + ).flat() + + if (stressBlocks.length > 0) { + tx.nodes.insertMany(stressBlocks, { at: [nonStressCount] }) + } }) }, [editor] diff --git a/site/examples/ts/yjs-collaboration.tsx b/site/examples/ts/yjs-collaboration.tsx new file mode 100644 index 0000000000..46526c3fa9 --- /dev/null +++ b/site/examples/ts/yjs-collaboration.tsx @@ -0,0 +1,1373 @@ +import { + createYjsExtension, + type YjsAwarenessChange, + type YjsTx, +} from '@slate/yjs' +import { useYjsRemoteCursors } from '@slate/yjs/react' +import type { KeyboardEvent, MouseEvent, PointerEvent } from 'react' +import { useMemo, useState } from 'react' +import { createEditor, NodeApi, type Operation, type Range } from 'slate' +import { history } from 'slate-history' +import { + Editable, + type RenderElementProps, + type RenderLeafProps, + Slate, + useSlateEditor, +} from 'slate-react' +import * as Y from 'yjs' + +import { Button } from '@/components/ui/button' +import { cn } from '@/utils/cn' + +import type { + CustomEditor, + CustomElement, + CustomText, + CustomValue, +} from './custom-types.d' + +type PeerId = 'a' | 'b' | 'c' | 'd' + +type PeerDefinition = { + appendText: string + clientId: number + id: PeerId + name: string + replacementText: string +} + +type ExamplePeer = PeerDefinition & { + awareness: ExampleAwareness + connected: boolean + doc: Y.Doc + editor?: CustomEditor + redoDepth: number + renderEpoch: number + undoDepth: number +} + +type ExampleNetwork = { + notify: () => void + peers: ExamplePeer[] + recordLocalChange: ( + peer: ExamplePeer, + operations: readonly Operation[] + ) => void + runWithoutLocalHistory: (fn: () => void) => void + syncAll: () => void + syncAwareness: () => void + syncing: boolean +} + +const PEERS: PeerDefinition[] = [ + { + appendText: ' Ada', + clientId: 101, + id: 'a', + name: 'Ada', + replacementText: 'Ada canonical snapshot.', + }, + { + appendText: ' Lin', + clientId: 202, + id: 'b', + name: 'Lin', + replacementText: 'Lin canonical snapshot.', + }, + { + appendText: ' Ken', + clientId: 303, + id: 'c', + name: 'Ken', + replacementText: 'Ken canonical snapshot.', + }, + { + appendText: ' Eve', + clientId: 404, + id: 'd', + name: 'Eve', + replacementText: 'Eve canonical snapshot.', + }, +] as const + +const INITIAL_VALUE: CustomValue = [ + { + type: 'paragraph', + children: [{ text: 'Hello world!' }], + }, +] + +class ExampleAwareness { + readonly clientID: number + readonly doc: { clientID: number } + + onLocalStateChange?: () => void + + private readonly listeners = new Set<(event: YjsAwarenessChange) => void>() + private localState: Record | null = null + private readonly states = new Map>() + + constructor(clientID: number) { + this.clientID = clientID + this.doc = { clientID } + } + + getLocalState() { + return this.localState + } + + getStates() { + return this.states + } + + off(_event: 'change', handler: (event: YjsAwarenessChange) => void) { + this.listeners.delete(handler) + } + + on(_event: 'change', handler: (event: YjsAwarenessChange) => void) { + this.listeners.add(handler) + } + + removeRemoteState(clientId: number) { + if (!this.states.delete(clientId)) { + return + } + + this.emit() + } + + setLocalStateField(field: string, value: unknown) { + this.localState = { + ...(this.localState ?? {}), + [field]: value, + } + this.states.set(this.clientID, this.localState) + this.emit() + this.onLocalStateChange?.() + } + + setRemoteState(clientId: number, state: Record) { + this.states.set(clientId, state) + this.emit() + } + + private emit() { + const event: YjsAwarenessChange = { + added: [], + removed: [], + updated: [this.clientID], + } + + for (const listener of this.listeners) { + listener(event) + } + } +} + +const paragraph = (text: string): CustomElement => ({ + type: 'paragraph', + children: [{ text }], +}) + +const cloneValue = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const yjsTx = (tx: unknown) => (tx as { yjs: YjsTx }).yjs + +const createSeedUpdate = () => { + const seedDoc = new Y.Doc() + + createEditor({ + extensions: [ + createYjsExtension({ + clientId: 'seed', + doc: seedDoc, + rootName: 'slate', + }), + ], + initialValue: cloneValue(INITIAL_VALUE), + }) + + return Y.encodeStateAsUpdate(seedDoc) +} + +const createExampleNetwork = (): ExampleNetwork => { + const seedUpdate = createSeedUpdate() + const peers: ExamplePeer[] = PEERS.map((definition) => { + const doc = new Y.Doc() + + doc.clientID = definition.clientId + Y.applyUpdate(doc, seedUpdate) + + return { + ...definition, + awareness: new ExampleAwareness(definition.clientId), + connected: true, + doc, + redoDepth: 0, + renderEpoch: 0, + undoDepth: 0, + } + }) + + const network: ExampleNetwork = { + notify: () => {}, + peers, + recordLocalChange(peer, operations) { + if (network.syncing) { + return + } + if ( + operations.length === 0 || + operations.some((operation) => operation.type !== 'set_selection') + ) { + peer.undoDepth += 1 + peer.redoDepth = 0 + } + }, + runWithoutLocalHistory(fn) { + const wasSyncing = network.syncing + + network.syncing = true + try { + fn() + } finally { + network.syncing = wasSyncing + } + }, + syncAll() { + if (network.syncing) { + return + } + + network.syncing = true + + try { + for (const source of peers) { + if (!source.connected) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !target.connected) { + continue + } + + Y.applyUpdate(target.doc, update, source.doc) + } + } + + for (const peer of peers) { + if (!peer.connected || !peer.editor) { + continue + } + + peer.editor.update((tx) => { + yjsTx(tx).reconcile() + }) + } + + network.syncAwareness() + } finally { + network.syncing = false + } + + network.notify() + }, + syncAwareness() { + for (const target of peers) { + for (const source of peers) { + if (source === target) { + continue + } + + const localState = source.awareness.getLocalState() + + if (source.connected && target.connected && localState) { + target.awareness.setRemoteState(source.clientId, localState) + } else { + target.awareness.removeRemoteState(source.clientId) + } + } + } + + network.notify() + }, + syncing: false, + } + + for (const peer of peers) { + peer.awareness.onLocalStateChange = () => network.syncAwareness() + } + + return network +} + +const getEditorValue = (editor: CustomEditor): CustomValue => + editor.read((state) => + cloneValue(state.value.get().roots.main) + ) as CustomValue + +const getBlockText = (editor: CustomEditor, index: number) => + editor.read((state) => { + const node = state.nodes.children()[index] + + return node ? NodeApi.string(node) : '' + }) + +const getParagraphCount = (editor: CustomEditor) => + editor.read((state) => state.nodes.children().length) + +const clearPeerHistory = (peer: ExamplePeer) => { + peer.undoDepth = 0 + peer.redoDepth = 0 +} + +const documentText = (editor: CustomEditor) => + getEditorValue(editor) + .map((node) => NodeApi.string(node)) + .join('\n') + +const selectedText = (editor: CustomEditor) => + editor.api.dom.getWindow().getSelection()?.toString().replaceAll('\uFEFF', '') + +const hasCanonicalSnapshot = (editor: CustomEditor) => + getBlockText(editor, 0).includes('canonical snapshot.') + +const syncSelectionFromDom = (editor: CustomEditor) => { + const selection = editor.api.dom.getWindow().getSelection() + + if (!selection || selection.rangeCount === 0) { + return + } + + const range = editor.api.dom.resolveSlateRange(selection, { + exactMatch: false, + }) + + if (!range) { + return + } + + editor.update((tx) => { + tx.selection.set(range) + }) +} + +const runPeerCommand = ( + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor, + command: (editor: CustomEditor) => void, + { undoable = true }: { undoable?: boolean } = {} +) => { + syncSelectionFromDom(editor) + command(editor) + + if (undoable) { + peer.undoDepth += 1 + peer.redoDepth = 0 + } + + network.syncAll() +} + +const setConnected = ( + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor, + connected: boolean +) => { + peer.connected = connected + editor.update((tx) => { + if (connected) { + yjsTx(tx).connect() + } else { + yjsTx(tx).disconnect() + } + }) + + if (connected) { + network.syncAll() + + if (hasCanonicalSnapshot(editor)) { + clearPeerHistory(peer) + } + } else { + network.syncAwareness() + } + + network.notify() +} + +const selectHello = ( + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor +) => { + const range: Range = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 5 }, + } + + editor.update((tx) => { + tx.selection.set(range) + yjsTx(tx).sendSelection(range, { + name: peer.name, + }) + }) + + network.syncAwareness() +} + +const appendText = (peer: ExamplePeer, editor: CustomEditor) => { + const text = getBlockText(editor, 0) + + editor.update((tx) => { + tx.text.insert(peer.appendText, { + at: { path: [0, 0], offset: text.length }, + }) + }) +} + +const insertExclamation = (editor: CustomEditor) => { + const text = getBlockText(editor, 0) + + editor.update((tx) => { + tx.text.insert('!', { + at: { path: [0, 0], offset: text.length }, + }) + }) +} + +const selectDefaultBoldRange = (editor: CustomEditor) => { + const selection = editor.read((state) => + state.selection.get() + ) as Range | null + + if ( + selection && + (selection.anchor.path.join('.') !== selection.focus.path.join('.') || + selection.anchor.offset !== selection.focus.offset) + ) { + return + } + + const length = Math.min(5, getBlockText(editor, 0).length) + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: length }, + }) + }) +} + +const toggleBold = (editor: CustomEditor) => { + selectDefaultBoldRange(editor) + editor.update((tx) => { + tx.marks.toggle('bold') + }) +} + +const replaceDocument = (peer: ExamplePeer, editor: CustomEditor) => { + const value = getEditorValue(editor) + const [firstBlock] = value + const firstText = firstBlock ? NodeApi.string(firstBlock) : '' + const operations: Operation[] = [] + + for (let index = value.length - 1; index > 0; index--) { + operations.push({ + node: value[index]!, + path: [index], + root: 'main', + type: 'remove_node', + }) + } + + if (firstText.length > 0) { + operations.push({ + offset: 0, + path: [0, 0], + root: 'main', + text: firstText, + type: 'remove_text', + }) + } + + if (peer.replacementText.length > 0) { + operations.push({ + offset: 0, + path: [0, 0], + root: 'main', + text: peer.replacementText, + type: 'insert_text', + }) + } + + editor.update((tx) => { + tx.operations.replay(operations) + tx.selection.set({ + anchor: { path: [0, 0], offset: peer.replacementText.length }, + focus: { path: [0, 0], offset: peer.replacementText.length }, + }) + }) +} + +const replaceWithEmptyParagraph = (editor: CustomEditor) => { + const operation: Operation = { + children: getEditorValue(editor), + newChildren: [paragraph('')], + newSelection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + path: [], + root: 'main', + selection: null, + type: 'replace_fragment', + } + + editor.update((tx) => { + tx.operations.replay([operation]) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const replaceBlockTextWithEmpty = ( + editor: CustomEditor, + blockIndex: number +) => { + const value = getEditorValue(editor) + const block = value[blockIndex] + + if (!block || !('children' in block)) { + return + } + + const operation: Operation = { + children: block.children, + newChildren: [{ text: '' }], + newSelection: { + anchor: { path: [blockIndex, 0], offset: 0 }, + focus: { path: [blockIndex, 0], offset: 0 }, + }, + path: [blockIndex], + root: 'main', + selection: null, + type: 'replace_fragment', + } + + editor.update((tx) => { + tx.operations.replay([operation]) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const removeBlock = (editor: CustomEditor, blockIndex: number) => { + const value = getEditorValue(editor) + const node = value[blockIndex] + + if (!node) { + return + } + + if (value.length === 1) { + replaceWithEmptyParagraph(editor) + return + } + + editor.update((tx) => { + tx.operations.replay([ + { + node, + path: [blockIndex], + root: 'main', + type: 'remove_node', + }, + ]) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const shouldReplaceWholeDocumentSelection = ( + event: KeyboardEvent, + editor: CustomEditor +) => { + if (event.key !== 'Backspace' && event.key !== 'Delete') { + return false + } + + const text = selectedText(editor) + + return !!text && text === documentText(editor) +} + +const selectedParagraphNodeIndex = ( + event: KeyboardEvent, + editor: CustomEditor +) => { + const datasetIndex = event.currentTarget.dataset.yjsSelectedParagraphNode + + if (datasetIndex) { + delete event.currentTarget.dataset.yjsSelectedParagraphNode + + return Number(datasetIndex) + } + + const selection = editor.api.dom.getWindow().getSelection() + + if (!selection || selection.rangeCount === 0) { + return -1 + } + + const range = selection.getRangeAt(0) + + if ( + range.startContainer !== event.currentTarget || + range.endContainer !== event.currentTarget || + range.endOffset - range.startOffset !== 1 + ) { + return -1 + } + + const selectedNode = event.currentTarget.childNodes[range.startOffset] + + return [...event.currentTarget.querySelectorAll('p')].indexOf( + selectedNode as HTMLParagraphElement + ) +} + +const selectedBlockTextIndex = (editor: CustomEditor) => { + const text = selectedText(editor) + + if (!text) { + return -1 + } + + return getEditorValue(editor).findIndex( + (node) => NodeApi.string(node) === text + ) +} + +const handleDeleteKeyDown = ( + event: KeyboardEvent, + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor +) => { + if (!shouldReplaceWholeDocumentSelection(event, editor)) { + const nodeIndex = selectedParagraphNodeIndex(event, editor) + + if (nodeIndex !== -1) { + event.preventDefault() + runPeerCommand(network, peer, editor, () => + removeBlock(editor, nodeIndex) + ) + + return true + } + + const blockIndex = selectedBlockTextIndex(editor) + + if (blockIndex === -1) { + return false + } + + event.preventDefault() + runPeerCommand(network, peer, editor, () => + replaceBlockTextWithEmpty(editor, blockIndex) + ) + + return true + } + + event.preventDefault() + runPeerCommand(network, peer, editor, replaceWithEmptyParagraph) + + return true +} + +const splitFirstText = (peer: ExamplePeer, editor: CustomEditor) => { + const text = getBlockText(editor, 0) + const offset = Math.max(1, Math.floor(text.length / 2)) + const [block] = getEditorValue(editor) + + if (!block || !('children' in block)) { + return + } + + const [leaf] = block.children + + if (!leaf || !('text' in leaf)) { + return + } + + const { children: _children, ...elementProperties } = block + const { text: _text, ...textProperties } = leaf + + editor.update((tx) => { + tx.operations.replay([ + { + path: [0, 0], + position: offset, + properties: textProperties, + type: 'split_node', + }, + { + path: [0], + position: 1, + properties: elementProperties, + type: 'split_node', + }, + ]) + }) + peer.renderEpoch += 1 +} + +const wrapFirstBlock = (editor: CustomEditor) => { + editor.update((tx) => { + tx.selection.clear() + tx.nodes.wrap({ children: [], type: 'block-quote' }, { at: [0] }) + tx.selection.clear() + }) +} + +const ensureParagraphCount = (editor: CustomEditor, count: number) => { + const paragraphCount = getParagraphCount(editor) + + if (paragraphCount >= count) { + return + } + + editor.update((tx) => { + for (let index = paragraphCount; index < count; index++) { + tx.nodes.insert(paragraph(`block ${index + 1}`), { at: [index] }) + } + }) +} + +const removeSecondBlock = (editor: CustomEditor) => { + ensureParagraphCount(editor, 2) + + editor.update((tx) => { + tx.nodes.remove({ at: [1] }) + }) +} + +const mergeSecondBlock = (editor: CustomEditor) => { + ensureParagraphCount(editor, 2) + + editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) +} + +const moveFirstBlockDown = (editor: CustomEditor) => { + ensureParagraphCount(editor, 2) + + editor.update((tx) => { + tx.nodes.move({ at: [0], to: [1] }) + }) +} + +const setFirstBlock = (editor: CustomEditor) => { + editor.update((tx) => { + tx.nodes.set({ role: 'title', type: 'heading-one' } as never, { at: [0] }) + }) +} + +const unsetFirstBlock = (editor: CustomEditor) => { + editor.update((tx) => { + tx.nodes.unset('role' as never, { at: [0] }) + }) +} + +const firstBlockIsQuote = (editor: CustomEditor) => { + const [firstBlock] = getEditorValue(editor) + + return firstBlock && 'type' in firstBlock && firstBlock.type === 'block-quote' +} + +const unwrapFirstBlock = (editor: CustomEditor) => { + if (!firstBlockIsQuote(editor)) { + wrapFirstBlock(editor) + } + + editor.update((tx) => { + tx.nodes.unwrap({ at: [0] }) + }) +} + +const liftFirstWrappedBlock = (editor: CustomEditor) => { + if (!firstBlockIsQuote(editor)) { + wrapFirstBlock(editor) + } + + editor.update((tx) => { + tx.nodes.lift({ at: [0, 0] }) + }) +} + +const insertFragmentText = (peer: ExamplePeer, editor: CustomEditor) => { + const text = getBlockText(editor, 0) + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: text.length }, + focus: { path: [0, 0], offset: text.length }, + }) + tx.fragment.insert([{ text: `${peer.name} fragment` }]) + }) +} + +const moveFirstBlockAfterSecond = (editor: CustomEditor) => { + if (getParagraphCount(editor) < 2) { + return + } + + editor.update((tx) => { + tx.nodes.move({ at: [0], to: [1] }) + }) +} + +const deleteFirstFragment = (editor: CustomEditor) => { + const text = getBlockText(editor, 0) + const length = Math.min(5, text.length) + + if (length === 0) { + return + } + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: length }, + }) + tx.fragment.delete() + }) +} + +const deleteBackwardFromFirstBlockEnd = (editor: CustomEditor) => { + const text = getBlockText(editor, 0) + + if (text.length === 0) { + return + } + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: text.length }, + focus: { path: [0, 0], offset: text.length }, + }) + tx.text.deleteBackward({ unit: 'character' }) + }) +} + +const undoPeer = ( + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor +) => { + if (peer.undoDepth === 0) { + return + } + + network.runWithoutLocalHistory(() => { + editor.update((tx) => { + yjsTx(tx).undo() + }) + }) + + peer.undoDepth = Math.max(0, peer.undoDepth - 1) + peer.redoDepth += 1 + network.syncAll() +} + +const redoPeer = ( + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor +) => { + if (peer.redoDepth === 0) { + return + } + + network.runWithoutLocalHistory(() => { + editor.update((tx) => { + yjsTx(tx).redo() + }) + }) + + peer.undoDepth += 1 + peer.redoDepth = Math.max(0, peer.redoDepth - 1) + network.syncAll() +} + +const handleHistoryKeyDown = ( + event: KeyboardEvent, + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor +) => { + const isModifier = event.metaKey || event.ctrlKey + + if (!isModifier || event.key.toLowerCase() !== 'z') { + return + } + + event.preventDefault() + + if (event.shiftKey) { + const redoDepth = peer.redoDepth + + for (let index = 0; index < redoDepth; index++) { + redoPeer(network, peer, editor) + } + } else { + const undoDepth = peer.undoDepth + + for (let index = 0; index < undoDepth; index++) { + undoPeer(network, peer, editor) + } + } +} + +const handleEditableKeyDown = ( + event: KeyboardEvent, + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor +) => { + if (handleDeleteKeyDown(event, network, peer, editor)) { + return + } + + handleHistoryKeyDown(event, network, peer, editor) +} + +const handleCommandClick = ( + event: MouseEvent, + command: () => void +) => { + if (event.detail === 0) { + command() + } +} + +const handleCommandPointerDown = ( + event: PointerEvent, + command: () => void +) => { + event.preventDefault() + command() +} + +const Element = ({ + attributes, + children, + element, +}: RenderElementProps) => { + switch (element.type) { + case 'block-quote': + return ( +
+ {children} +
+ ) + default: + return

{children}

+ } +} + +const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => { + if (leaf.bold) { + children = {children} + } + + return {children} +} + +const CursorStatus = ({ editor }: { editor: CustomEditor }) => { + const cursors = useYjsRemoteCursors(editor) + + return ( + + {cursors.length === 0 + ? 'remote:none' + : cursors + .map((cursor) => { + const selection = cursor.selection + + if (!selection) { + return `${cursor.clientId}:null` + } + + return `${cursor.clientId}:${selection.anchor.path.join('.')}:${ + selection.anchor.offset + }-${selection.focus.path.join('.')}:${selection.focus.offset}` + }) + .join(' | ')} + + ) +} + +const CommandButton = ({ + children, + className, + disabled, + onRun, + testId, +}: { + children: string + className?: string + disabled?: boolean + onRun: () => void + testId: string +}) => ( + +) + +const PeerPanel = ({ + network, + peer, + version: _version, +}: { + network: ExampleNetwork + peer: ExamplePeer + version: number +}) => { + const editor = useSlateEditor({ + extensions: [ + history(), + createYjsExtension({ + awareness: peer.awareness, + clientId: peer.id, + doc: peer.doc, + rootName: 'slate', + }), + ], + initialValue: cloneValue(INITIAL_VALUE), + }) as CustomEditor + + const canUndo = peer.undoDepth > 0 + const canRedo = peer.redoDepth > 0 + const connected = peer.connected + const label = `Peer ${peer.id.toUpperCase()}` + + peer.editor = editor + + return ( + { + if (network.syncing && hasCanonicalSnapshot(editor)) { + clearPeerHistory(peer) + } + network.recordLocalChange(peer, change.operations) + network.syncAll() + }} + > +
+
+
+

{label}

+
+ +
+
+ + {connected ? 'connected' : 'offline'} + +
+ +
+ selectHello(network, peer, editor)} + testId={`yjs-peer-${peer.id}-select`} + > + Select + + + runPeerCommand(network, peer, editor, (editor) => + toggleBold(editor) + ) + } + testId={`yjs-peer-${peer.id}-mark-bold`} + > + Bold + + setConnected(network, peer, editor, false)} + testId={`yjs-peer-${peer.id}-disconnect`} + > + Offline + + setConnected(network, peer, editor, true)} + testId={`yjs-peer-${peer.id}-connect`} + > + Online + + { + editor.update((tx) => { + yjsTx(tx).reconcile() + }) + network.notify() + }} + testId={`yjs-peer-${peer.id}-reconcile`} + > + Reconcile + + undoPeer(network, peer, editor)} + testId={`yjs-peer-${peer.id}-undo`} + > + Undo + + redoPeer(network, peer, editor)} + testId={`yjs-peer-${peer.id}-redo`} + > + Redo + +
+ +
+ + runPeerCommand(network, peer, editor, () => + appendText(peer, editor) + ) + } + testId={`yjs-peer-${peer.id}-append`} + > + Append + + + runPeerCommand(network, peer, editor, () => + replaceDocument(peer, editor) + ) + } + testId={`yjs-peer-${peer.id}-replace`} + > + Replace + + + runPeerCommand(network, peer, editor, () => + removeSecondBlock(editor) + ) + } + testId={`yjs-peer-${peer.id}-remove-node`} + > + Remove + + + runPeerCommand(network, peer, editor, () => + splitFirstText(peer, editor) + ) + } + testId={`yjs-peer-${peer.id}-split-node`} + > + Split + + + runPeerCommand(network, peer, editor, () => + mergeSecondBlock(editor) + ) + } + testId={`yjs-peer-${peer.id}-merge-node`} + > + Merge + + + runPeerCommand(network, peer, editor, () => + moveFirstBlockDown(editor) + ) + } + testId={`yjs-peer-${peer.id}-move-down`} + > + Down + + + runPeerCommand(network, peer, editor, () => setFirstBlock(editor)) + } + testId={`yjs-peer-${peer.id}-set-node`} + > + Set + + + runPeerCommand(network, peer, editor, () => + unsetFirstBlock(editor) + ) + } + testId={`yjs-peer-${peer.id}-unset-node`} + > + Unset + + + runPeerCommand(network, peer, editor, () => + wrapFirstBlock(editor) + ) + } + testId={`yjs-peer-${peer.id}-wrap-node`} + > + Wrap + + + runPeerCommand(network, peer, editor, () => + unwrapFirstBlock(editor) + ) + } + testId={`yjs-peer-${peer.id}-unwrap`} + > + Unwrap + + + runPeerCommand(network, peer, editor, () => + liftFirstWrappedBlock(editor) + ) + } + testId={`yjs-peer-${peer.id}-lift`} + > + Lift + + + runPeerCommand(network, peer, editor, () => + insertFragmentText(peer, editor) + ) + } + testId={`yjs-peer-${peer.id}-insert-fragment`} + > + Fragment + + + runPeerCommand(network, peer, editor, () => + deleteFirstFragment(editor) + ) + } + testId={`yjs-peer-${peer.id}-delete-fragment`} + > + Delete + + + runPeerCommand(network, peer, editor, () => + deleteBackwardFromFirstBlockEnd(editor) + ) + } + testId={`yjs-peer-${peer.id}-delete-backward`} + > + Back + + + runPeerCommand(network, peer, editor, () => + insertExclamation(editor) + ) + } + testId={`yjs-peer-${peer.id}-insert-text`} + > + Insert ! + + + runPeerCommand(network, peer, editor, () => + moveFirstBlockAfterSecond(editor) + ) + } + testId={`yjs-peer-${peer.id}-move`} + > + Move + +
+ +
+ + handleEditableKeyDown(event, network, peer, editor) + } + placeholder="Start typing" + renderElement={Element} + renderLeaf={Leaf} + spellCheck={false} + /> +
+
+
+ ) +} + +const YjsCollaborationExample = () => { + const network = useMemo(() => createExampleNetwork(), []) + const [version, setVersion] = useState(0) + + network.notify = () => { + setVersion((current) => current + 1) + } + + return ( +
+
+ {network.peers.map((peer) => ( + + ))} +
+
+ ) +} + +export default YjsCollaborationExample diff --git a/site/pages/examples/[example].tsx b/site/pages/examples/[example].tsx index 9c467f835c..ce58c5e591 100644 --- a/site/pages/examples/[example].tsx +++ b/site/pages/examples/[example].tsx @@ -51,6 +51,7 @@ const EXAMPLE_IMPORTERS: Record< styling: () => import('../../examples/ts/styling'), 'synced-blocks': () => import('../../examples/ts/synced-blocks'), tables: () => import('../../examples/ts/tables'), + 'yjs-collaboration': () => import('../../examples/ts/yjs-collaboration'), } const EXAMPLES: ExampleTuple[] = EXAMPLE_NAMES_AND_PATHS.map(([name, path]) => [ diff --git a/site/tsconfig.json b/site/tsconfig.json index eee48f8917..143b81cefc 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -16,6 +16,9 @@ "incremental": true, "paths": { "@/*": ["./*"], + "@slate/yjs": ["../packages/slate-yjs/src/index.ts"], + "@slate/yjs/core": ["../packages/slate-yjs/src/core/index.ts"], + "@slate/yjs/react": ["../packages/slate-yjs/src/react/index.ts"], "slate": ["../packages/slate/src/index.ts"], "slate-browser": ["../packages/slate-browser/src/index.ts"], "slate-browser/*": ["../packages/slate-browser/src/*"], From e66fa0738aa3d0a7c8344d419d6689ddb5974b03 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sun, 31 May 2026 23:22:43 +0800 Subject: [PATCH 02/11] fix(yjs): align keyboard undo cursor state --- ...eyboard-undo-cursor-grouping-2026-05-31.md | 98 +++++ .../examples/yjs-collaboration.test.ts | 46 ++- site/examples/ts/yjs-collaboration.tsx | 370 ++++++++++++++++-- 3 files changed, 481 insertions(+), 33 deletions(-) create mode 100644 docs/solutions/ui-bugs/yjs-keyboard-undo-cursor-grouping-2026-05-31.md diff --git a/docs/solutions/ui-bugs/yjs-keyboard-undo-cursor-grouping-2026-05-31.md b/docs/solutions/ui-bugs/yjs-keyboard-undo-cursor-grouping-2026-05-31.md new file mode 100644 index 0000000000..14c0a50266 --- /dev/null +++ b/docs/solutions/ui-bugs/yjs-keyboard-undo-cursor-grouping-2026-05-31.md @@ -0,0 +1,98 @@ +--- +title: Yjs demo keyboard undo needs user-intent groups +date: 2026-05-31 +category: docs/solutions/ui-bugs +module: slate-yjs demo +problem_type: ui_bug +component: tooling +symptoms: + - Keyboard Undo removed more edits than the toolbar Undo button. + - Button Undo and keyboard Undo disagreed after repeated command controls. + - The local and remote cursor could stay at the pre-undo offset. +root_cause: logic_error +resolution_type: code_fix +severity: medium +tags: [slate-yjs, undo-redo, keyboard, cursor, playwright] +--- + +# Yjs demo keyboard undo needs user-intent groups + +## Problem + +The collaboration demo used a flat local undo counter for keyboard history. +That made `Cmd+Z` / `Ctrl+Z` drain every pending local edit while the toolbar +button only replayed one Yjs undo item. After undo, the Slate selection and +remote cursor display could still point at the old document offset. + +## Symptoms + +- Two Append button clicks followed by one keyboard Undo removed both appends. +- Offline keyboard edits needed multiple low-level Yjs undos to revert one user + intent, such as `Enter` followed by typing a word. +- Remote cursor status stayed at the old selection after history replay. + +## What Didn't Work + +- Replacing the keyboard path with a single Yjs undo made command buttons match, + but native typing only reverted one low-level Yjs item. +- Replacing the keyboard path with Slate history respected typing groups, but + command buttons could merge into one Slate history batch and undo too much. +- Convergence-only Playwright assertions missed the cursor regression. + +## Solution + +Track demo history as user-intent groups instead of a flat count: + +```ts +type ExampleUndoGroup = { + kind: 'command' | 'keyboard' + size: number +} +``` + +Command controls create their own groups. Consecutive keyboard text edits merge +into one group, while each Enter key starts a new group so repeated empty +paragraph splits remain independently undoable. Undo and redo pop one group and +replay exactly that many Yjs history items. + +After history replay, normalize the local Slate selection against the current +document and send it through Yjs awareness: + +```ts +tx.selection.set(selection) +yjsTx(tx).sendSelection(selection, { name: peer.name }) +``` + +Test setup edits use a dedicated tag so fixture creation does not pollute the +demo's user undo stack: + +```ts +handle.applyOperations(operations, { tag: 'yjs-example-test-setup' }) +``` + +## Why This Works + +Yjs history stores low-level document transactions, but the demo UI exposes +user-facing undo. A keyboard word, a split plus typed text, and a toolbar command +are user intents even when they produce different numbers of Yjs stack items. +Grouping gives keyboard and button controls the same unit of work without +falling back to snapshots. + +Selection repair is separate from document replay. History changes the document, +then the demo clamps the local cursor to a valid text point and publishes that +selection as awareness so remote cursor UI follows the reverted state. + +## Prevention + +- Do not model collaboration undo UI with a flat integer when low-level history + can split one user intent into several Yjs items. +- Browser tests for history controls should assert one keyboard shortcut, + document convergence, local Slate selection, and remote cursor awareness. +- Test setup operations should be traceable and excluded from user history. + +## Related Issues + +- `docs/solutions/developer-experience/yjs-awareness-react-hooks-2026-05-29.md` +- `docs/solutions/logic-errors/yjs-offline-merge-stale-undo-2026-05-26.md` +- `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md` + diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts index 20689620e8..76adf725b8 100644 --- a/playwright/integration/examples/yjs-collaboration.test.ts +++ b/playwright/integration/examples/yjs-collaboration.test.ts @@ -129,7 +129,7 @@ const replacePeerText = async ( }) }) - handle.applyOperations(operations) + handle.applyOperations(operations, { tag: 'yjs-example-test-setup' }) }, paragraphs) } @@ -567,6 +567,50 @@ test.describe('yjs collaboration example', () => { await expect(peerSurface(page, 'b')).toContainText(typedText) }) + test('keyboard undo matches one toolbar undo step and publishes the cursor', async ({ + page, + }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + const initialText = 'Hello world!' + const appendedOnce = `${initialText} Ada` + const appendedTwice = `${appendedOnce} Ada` + const nextSelection = { + anchor: { path: [0, 0], offset: appendedOnce.length }, + focus: { path: [0, 0], offset: appendedOnce.length }, + } + + await editor.selection.selectDOM({ + anchor: { path: [0, 0], offset: initialText.length }, + focus: { path: [0, 0], offset: initialText.length }, + }) + await byTestId(page, 'yjs-peer-a-append').click() + await byTestId(page, 'yjs-peer-a-append').click() + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual([appendedTwice]) + } + + await peerTextbox(page, 'a').focus() + const { undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerParagraphTexts(page, peer)) + .toEqual([appendedOnce]) + } + await expect.poll(() => editor.selection.get()).toEqual(nextSelection) + await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( + `101:0.0:${appendedOnce.length}-0.0:${appendedOnce.length}` + ) + }) + test('keeps peers usable after selecting all and deleting', async ({ page, }) => { diff --git a/site/examples/ts/yjs-collaboration.tsx b/site/examples/ts/yjs-collaboration.tsx index 46526c3fa9..b9d02ac1f8 100644 --- a/site/examples/ts/yjs-collaboration.tsx +++ b/site/examples/ts/yjs-collaboration.tsx @@ -6,7 +6,13 @@ import { import { useYjsRemoteCursors } from '@slate/yjs/react' import type { KeyboardEvent, MouseEvent, PointerEvent } from 'react' import { useMemo, useState } from 'react' -import { createEditor, NodeApi, type Operation, type Range } from 'slate' +import { + createEditor, + type Descendant, + NodeApi, + type Operation, + type Range, +} from 'slate' import { history } from 'slate-history' import { Editable, @@ -37,14 +43,25 @@ type PeerDefinition = { replacementText: string } +type ExampleUndoGroup = { + kind: 'command' | 'keyboard' + size: number +} + +type KeyboardInputType = 'delete' | 'enter' | 'text' + type ExamplePeer = PeerDefinition & { awareness: ExampleAwareness connected: boolean doc: Y.Doc editor?: CustomEditor + pendingKeyboardInputType?: KeyboardInputType + pendingLocalChangeKind?: ExampleUndoGroup['kind'] redoDepth: number + redoGroups: ExampleUndoGroup[] renderEpoch: number undoDepth: number + undoGroups: ExampleUndoGroup[] } type ExampleNetwork = { @@ -91,6 +108,32 @@ const PEERS: PeerDefinition[] = [ }, ] as const +const syncPeerHistoryDepths = (peer: ExamplePeer) => { + peer.undoDepth = peer.undoGroups.length + peer.redoDepth = peer.redoGroups.length +} + +const recordPeerUndoGroup = ( + peer: ExamplePeer, + kind: ExampleUndoGroup['kind'], + inputType?: KeyboardInputType +) => { + const previous = peer.undoGroups.at(-1) + + if ( + kind === 'keyboard' && + inputType !== 'enter' && + previous?.kind === 'keyboard' + ) { + previous.size += 1 + } else { + peer.undoGroups.push({ kind, size: 1 }) + } + + peer.redoGroups = [] + syncPeerHistoryDepths(peer) +} + const INITIAL_VALUE: CustomValue = [ { type: 'paragraph', @@ -205,8 +248,10 @@ const createExampleNetwork = (): ExampleNetwork => { connected: true, doc, redoDepth: 0, + redoGroups: [], renderEpoch: 0, undoDepth: 0, + undoGroups: [], } }) @@ -221,8 +266,11 @@ const createExampleNetwork = (): ExampleNetwork => { operations.length === 0 || operations.some((operation) => operation.type !== 'set_selection') ) { - peer.undoDepth += 1 - peer.redoDepth = 0 + recordPeerUndoGroup( + peer, + peer.pendingLocalChangeKind ?? 'keyboard', + peer.pendingKeyboardInputType + ) } }, runWithoutLocalHistory(fn) { @@ -310,6 +358,195 @@ const getEditorValue = (editor: CustomEditor): CustomValue => cloneValue(state.value.get().roots.main) ) as CustomValue +type TextEntry = { + path: number[] + text: string +} + +const isCustomText = (node: Descendant): node is CustomText => 'text' in node + +const hasDescendantChildren = ( + node: Descendant +): node is Descendant & { children: readonly Descendant[] } => + 'children' in node && Array.isArray(node.children) + +const findLastTextEntry = ( + nodes: readonly Descendant[], + basePath: number[] = [] +): TextEntry | null => { + for (let index = nodes.length - 1; index >= 0; index--) { + const node = nodes[index] + + if (!node) { + continue + } + + const path = [...basePath, index] + + if (isCustomText(node)) { + return { path, text: node.text } + } + + if (!hasDescendantChildren(node)) { + continue + } + + const entry = findLastTextEntry(node.children, path) + + if (entry) { + return entry + } + } + + return null +} + +const getTextEntryAtPath = ( + nodes: readonly Descendant[], + path: number[] +): TextEntry | null => { + let current: Descendant | undefined + let children: readonly Descendant[] = nodes + + for (let depth = 0; depth < path.length; depth++) { + const index = path[depth] + + if (index === undefined) { + return null + } + + current = children[index] + + if (!current) { + return null + } + + if (isCustomText(current)) { + return depth === path.length - 1 ? { path, text: current.text } : null + } + + if (!hasDescendantChildren(current)) { + return null + } + + children = current.children + } + + return current && isCustomText(current) ? { path, text: current.text } : null +} + +const pointAtTextEnd = (entry: TextEntry) => ({ + path: entry.path, + offset: entry.text.length, +}) + +const readEditorSelection = (editor: CustomEditor) => + editor.read((state) => state.selection.get()) as Range | null + +const isCollapsedSelection = (selection: Range) => + selection.anchor.path.join('.') === selection.focus.path.join('.') && + selection.anchor.offset === selection.focus.offset + +const isSelectionAtTextEnd = (value: CustomValue, selection: Range) => { + if (!isCollapsedSelection(selection)) { + return false + } + + const entry = getTextEntryAtPath(value, selection.anchor.path) + + return entry ? selection.anchor.offset === entry.text.length : false +} + +const normalizeHistorySelection = ( + value: CustomValue, + selection: Range | null, + options: { preferEndOfPreviousEndSelection?: Range | null } = {} +): Range | null => { + const fallbackEntry = findLastTextEntry(value) + + if (options.preferEndOfPreviousEndSelection) { + const entry = + getTextEntryAtPath( + value, + options.preferEndOfPreviousEndSelection.anchor.path + ) ?? fallbackEntry + + if (entry) { + const point = pointAtTextEnd(entry) + + return { anchor: point, focus: point } + } + } + + if (!selection) { + if (!fallbackEntry) { + return null + } + + const point = pointAtTextEnd(fallbackEntry) + + return { anchor: point, focus: point } + } + + const anchorEntry = getTextEntryAtPath(value, selection.anchor.path) + const focusEntry = getTextEntryAtPath(value, selection.focus.path) + + if (!anchorEntry || !focusEntry) { + if (!fallbackEntry) { + return null + } + + const point = pointAtTextEnd(fallbackEntry) + + return { anchor: point, focus: point } + } + + return { + anchor: { + path: anchorEntry.path, + offset: Math.min(selection.anchor.offset, anchorEntry.text.length), + }, + focus: { + path: focusEntry.path, + offset: Math.min(selection.focus.offset, focusEntry.text.length), + }, + } +} + +const syncPeerSelectionAfterHistory = ( + network: ExampleNetwork, + peer: ExamplePeer, + editor: CustomEditor, + previousValue: CustomValue, + previousSelection: Range | null +) => { + const value = getEditorValue(editor) + const selection = normalizeHistorySelection( + value, + readEditorSelection(editor), + { + preferEndOfPreviousEndSelection: + previousSelection && + isSelectionAtTextEnd(previousValue, previousSelection) + ? previousSelection + : null, + } + ) + + if (!selection) { + network.syncAwareness() + return + } + + editor.update((tx) => { + tx.selection.set(selection) + yjsTx(tx).sendSelection(selection, { + name: peer.name, + }) + }) + network.syncAwareness() +} + const getBlockText = (editor: CustomEditor, index: number) => editor.read((state) => { const node = state.nodes.children()[index] @@ -321,8 +558,9 @@ const getParagraphCount = (editor: CustomEditor) => editor.read((state) => state.nodes.children().length) const clearPeerHistory = (peer: ExamplePeer) => { - peer.undoDepth = 0 - peer.redoDepth = 0 + peer.undoGroups = [] + peer.redoGroups = [] + syncPeerHistoryDepths(peer) } const documentText = (editor: CustomEditor) => @@ -364,11 +602,15 @@ const runPeerCommand = ( { undoable = true }: { undoable?: boolean } = {} ) => { syncSelectionFromDom(editor) - command(editor) + const previousKind = peer.pendingLocalChangeKind - if (undoable) { - peer.undoDepth += 1 - peer.redoDepth = 0 + peer.pendingLocalChangeKind = undoable ? 'command' : undefined + try { + editor.api.history.withNewBatch(() => { + command(editor) + }) + } finally { + peer.pendingLocalChangeKind = previousKind } network.syncAll() @@ -424,21 +666,31 @@ const selectHello = ( const appendText = (peer: ExamplePeer, editor: CustomEditor) => { const text = getBlockText(editor, 0) + const offset = text.length + peer.appendText.length editor.update((tx) => { tx.text.insert(peer.appendText, { at: { path: [0, 0], offset: text.length }, }) + tx.selection.set({ + anchor: { path: [0, 0], offset }, + focus: { path: [0, 0], offset }, + }) }) } const insertExclamation = (editor: CustomEditor) => { const text = getBlockText(editor, 0) + const offset = text.length + 1 editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: text.length }, }) + tx.selection.set({ + anchor: { path: [0, 0], offset }, + focus: { path: [0, 0], offset }, + }) }) } @@ -871,18 +1123,32 @@ const undoPeer = ( peer: ExamplePeer, editor: CustomEditor ) => { - if (peer.undoDepth === 0) { + const group = peer.undoGroups.pop() + + if (!group) { return } + const previousValue = getEditorValue(editor) + const previousSelection = readEditorSelection(editor) + network.runWithoutLocalHistory(() => { - editor.update((tx) => { - yjsTx(tx).undo() - }) + for (let index = 0; index < group.size; index++) { + editor.update((tx) => { + yjsTx(tx).undo() + }) + } }) - peer.undoDepth = Math.max(0, peer.undoDepth - 1) - peer.redoDepth += 1 + peer.redoGroups.push(group) + syncPeerHistoryDepths(peer) + syncPeerSelectionAfterHistory( + network, + peer, + editor, + previousValue, + previousSelection + ) network.syncAll() } @@ -891,18 +1157,32 @@ const redoPeer = ( peer: ExamplePeer, editor: CustomEditor ) => { - if (peer.redoDepth === 0) { + const group = peer.redoGroups.pop() + + if (!group) { return } + const previousValue = getEditorValue(editor) + const previousSelection = readEditorSelection(editor) + network.runWithoutLocalHistory(() => { - editor.update((tx) => { - yjsTx(tx).redo() - }) + for (let index = 0; index < group.size; index++) { + editor.update((tx) => { + yjsTx(tx).redo() + }) + } }) - peer.undoDepth += 1 - peer.redoDepth = Math.max(0, peer.redoDepth - 1) + peer.undoGroups.push(group) + syncPeerHistoryDepths(peer) + syncPeerSelectionAfterHistory( + network, + peer, + editor, + previousValue, + previousSelection + ) network.syncAll() } @@ -915,24 +1195,38 @@ const handleHistoryKeyDown = ( const isModifier = event.metaKey || event.ctrlKey if (!isModifier || event.key.toLowerCase() !== 'z') { - return + return false } event.preventDefault() + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() if (event.shiftKey) { - const redoDepth = peer.redoDepth - - for (let index = 0; index < redoDepth; index++) { - redoPeer(network, peer, editor) - } + redoPeer(network, peer, editor) } else { - const undoDepth = peer.undoDepth + undoPeer(network, peer, editor) + } - for (let index = 0; index < undoDepth; index++) { - undoPeer(network, peer, editor) - } + return true +} + +const getKeyboardInputType = ( + event: KeyboardEvent +): KeyboardInputType | null => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return null + } + + if (event.key === 'Enter') { + return 'enter' + } + + if (event.key === 'Backspace' || event.key === 'Delete') { + return 'delete' } + + return event.key.length === 1 ? 'text' : null } const handleEditableKeyDown = ( @@ -941,6 +1235,13 @@ const handleEditableKeyDown = ( peer: ExamplePeer, editor: CustomEditor ) => { + const keyboardInputType = getKeyboardInputType(event) + + if (keyboardInputType) { + peer.pendingLocalChangeKind = 'keyboard' + peer.pendingKeyboardInputType = keyboardInputType + } + if (handleDeleteKeyDown(event, network, peer, editor)) { return } @@ -1080,7 +1381,12 @@ const PeerPanel = ({ if (network.syncing && hasCanonicalSnapshot(editor)) { clearPeerHistory(peer) } - network.recordLocalChange(peer, change.operations) + if ( + !change.tags.includes('historic') && + !change.tags.includes('yjs-example-test-setup') + ) { + network.recordLocalChange(peer, change.operations) + } network.syncAll() }} > From 896ca7e53c77d3057a9fe49bf9d680aa17e827f1 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Tue, 2 Jun 2026 17:56:09 +0800 Subject: [PATCH 03/11] fix(yjs): harden collaboration history replay --- .changeset/slate-yjs-history-redo.md | 5 + .gitattributes | 5 + autoresearch.ideas.md | 3 + autoresearch.jsonl | 1 + autoresearch.md | 52 +++ autoresearch.research/yjs-pr20/brief.md | 19 + .../yjs-pr20/gate-evidence.md | 95 +++++ .../yjs-pr20/perf-evidence.md | 50 +++ autoresearch.research/yjs-pr20/plan.md | 13 + .../yjs-pr20/quality-gaps.md | 26 ++ autoresearch.research/yjs-pr20/sources.md | 20 + autoresearch.research/yjs-pr20/synthesis.md | 51 +++ autoresearch.research/yjs-pr20/tasks.md | 24 ++ autoresearch.sh | 4 + ...-06-01-yjs-command-helper-bug-reduction.md | 38 ++ .../2026-06-01-yjs-history-undo-redo-bugs.md | 48 +++ ...split-history-dependent-redo-2026-06-01.md | 110 +++++ ...uctural-wrap-fragment-parity-2026-05-28.md | 45 +- ...emo-command-stale-text-paths-2026-06-01.md | 109 +++++ packages/slate-yjs/src/core/controller.ts | 54 ++- packages/slate-yjs/src/core/document.ts | 20 +- packages/slate-yjs/src/core/operations.ts | 154 +++++-- .../src/core/undo-manager-adapter.ts | 3 + .../test/replace-fragment-contract.spec.ts | 44 ++ .../test/simple-operations-contract.spec.ts | 39 ++ .../test/split-node-contract.spec.ts | 107 ++++- .../undo-manager-adapter-contract.spec.ts | 17 +- .../test/wrap-nodes-contract.spec.ts | 35 +- .../examples/yjs-collaboration.test.ts | 389 ++++++++++++++++++ .../core/current/yjs-collaboration.mjs | 389 ++++++++++++++++++ site/examples/ts/yjs-collaboration.tsx | 351 +++++++++++----- 31 files changed, 2140 insertions(+), 180 deletions(-) create mode 100644 .changeset/slate-yjs-history-redo.md create mode 100644 autoresearch.ideas.md create mode 100644 autoresearch.jsonl create mode 100644 autoresearch.md create mode 100644 autoresearch.research/yjs-pr20/brief.md create mode 100644 autoresearch.research/yjs-pr20/gate-evidence.md create mode 100644 autoresearch.research/yjs-pr20/perf-evidence.md create mode 100644 autoresearch.research/yjs-pr20/plan.md create mode 100644 autoresearch.research/yjs-pr20/quality-gaps.md create mode 100644 autoresearch.research/yjs-pr20/sources.md create mode 100644 autoresearch.research/yjs-pr20/synthesis.md create mode 100644 autoresearch.research/yjs-pr20/tasks.md create mode 100755 autoresearch.sh create mode 100644 docs/plans/2026-06-01-yjs-command-helper-bug-reduction.md create mode 100644 docs/plans/2026-06-01-yjs-history-undo-redo-bugs.md create mode 100644 docs/solutions/logic-errors/yjs-split-history-dependent-redo-2026-06-01.md create mode 100644 docs/solutions/ui-bugs/yjs-demo-command-stale-text-paths-2026-06-01.md create mode 100644 scripts/benchmarks/core/current/yjs-collaboration.mjs diff --git a/.changeset/slate-yjs-history-redo.md b/.changeset/slate-yjs-history-redo.md new file mode 100644 index 0000000000..71aa920a51 --- /dev/null +++ b/.changeset/slate-yjs-history-redo.md @@ -0,0 +1,5 @@ +--- +"@slate/yjs": patch +--- + +Fix split history undo and redo preserving concurrent edits, right-side text, no-op replace fragments, and cursor repair. diff --git a/.gitattributes b/.gitattributes index 6313b56c57..31dcb0e51c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,6 @@ * text=auto eol=lf + +# Codex Autoresearch ledger files +autoresearch.jsonl text eol=lf +autoresearch.md text eol=lf +autoresearch.ideas.md text eol=lf diff --git a/autoresearch.ideas.md b/autoresearch.ideas.md new file mode 100644 index 0000000000..5fa730689d --- /dev/null +++ b/autoresearch.ideas.md @@ -0,0 +1,3 @@ +# Autoresearch Ideas: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +- Add promising research-backed ideas here when they are not tried immediately. diff --git a/autoresearch.jsonl b/autoresearch.jsonl new file mode 100644 index 0000000000..3c907b9bff --- /dev/null +++ b/autoresearch.jsonl @@ -0,0 +1 @@ +{"type":"config","name":"Deep research: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf.","goal":"Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf.","metricName":"quality_gap","metricUnit":"gaps","bestDirection":"lower"} diff --git a/autoresearch.md b/autoresearch.md new file mode 100644 index 0000000000..908569aa0c --- /dev/null +++ b/autoresearch.md @@ -0,0 +1,52 @@ +# Autoresearch: Deep research: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## Objective +Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## Metrics +- Primary: quality_gap (gaps, lower is better) +- Secondary: none yet + +## How to Run +`./autoresearch.sh` prints `METRIC name=value` lines. + +## Files in Scope +- autoresearch.research/yjs-pr20 + +## Off Limits +- TBD: add off-limits files or behaviors if needed + +## Constraints +- - Decision contract: quality_gap is treated as a quality-bearing score; faster runs should not be promoted when component evidence shows quality or correctness erosion. +- Keep research notes under autoresearch.research/yjs-pr20. +- Use source-backed evidence before implementing recommendations. + +## Decision Rules +- Keep when the primary metric improves or a baseline is needed and checks pass. +- Discard when the metric is equal or worse, unless the run only establishes the baseline. +- Log crashes and failed checks with a concrete rollback reason. +- Put next-step guidance in ASI so another Codex session can continue. + +## Stop Conditions +- Stop when the target metric reaches the agreed threshold. +- For qualitative loops, stop when `quality_gap=0`, checks pass, and no high-impact open finding remains. +- Stop when maxIterations is reached or the user interrupts. + +## Research Notes +- Source-backed facts, contradictions, and open questions go here or in linked scratchpad files. +- For deep research loops, link the scratchpad folder and summarize the current synthesis. + +## What's Been Tried +- Baseline: `scripts/benchmarks/core/current/yjs-collaboration.mjs` measures real `@slate/yjs` multi-editor sync, awareness updates, reconnect, and large-doc sync. Primary metric `yjs_collaboration_worst_p95_ms=122.33`; focused package gates and `bun check` pass; existing Yjs browser offline replace failure blocks optimization promotion. + +## Resume This Session + +Use these commands to pick the loop back up without rediscovering state: + +```bash +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" state --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" doctor --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" --check-benchmark +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" next --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" log --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" --from-last --status keep --description "Describe the kept change" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" export --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" +``` diff --git a/autoresearch.research/yjs-pr20/brief.md b/autoresearch.research/yjs-pr20/brief.md new file mode 100644 index 0000000000..dd3ec007e3 --- /dev/null +++ b/autoresearch.research/yjs-pr20/brief.md @@ -0,0 +1,19 @@ +# Research Brief: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## Request +Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## Decision To Support +- Identify source-backed changes worth testing through an autoresearch loop. + +## Success Criteria +- The project essence is accurate. +- Sources and direct evidence are logged. +- High-impact findings are converted into quality gaps. +- Each implemented or rejected gap has evidence. + +## Constraints +- TBD: add constraints as they are discovered + +## Known Unknowns +- TBD: add unresolved questions before delegating or implementing. diff --git a/autoresearch.research/yjs-pr20/gate-evidence.md b/autoresearch.research/yjs-pr20/gate-evidence.md new file mode 100644 index 0000000000..2632ecb516 --- /dev/null +++ b/autoresearch.research/yjs-pr20/gate-evidence.md @@ -0,0 +1,95 @@ +# Gate Evidence: yjs-pr20 + +Date: 2026-06-02 +Target cwd: `/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2` + +## Summary + +| Gate | Status | Runtime | Evidence | Route | +| --- | --- | ---: | --- | --- | +| `bun check` repeat 1 | pass | 38.28s | lint, typecheck, Bun tests, Slate React Vitest all passed. | none | +| `bun check` repeat 2 | pass | 22.72s | same gate passed again with cached package typecheck. | none | +| `bun test ./packages/slate-yjs/test` | pass | 0.32s | 106 pass, 0 fail across 16 files. This is the current replacement for the stale `core-contract` filename. | none | +| Yjs collaboration Playwright full file | checks_failed | 89.04s | 41 passed, 1 failed. Failure: `preserves remote appends when an offline replace is undone before reconnect`. | `slate-patch` | +| Yjs collaboration Playwright failed grep rerun | checks_failed | 11.26s | Same test failed with same signal. Peer B stayed `Hello world!` after clicking offline Replace; expected `Lin canonical snapshot.` | `slate-patch` | +| `bun check:full` | checks_failed | 21.37s | Embedded `bun check` passed, then `test:release-discipline` failed before browser integration. | release-discipline patch | +| `bun test ./packages/slate/test/escape-hatch-inventory-contract.ts --bail 1` | checks_failed | 0.68s | Same escape-hatch inventory count drift reproduced. | release-discipline patch | + +## Stable Failure 1: Yjs Offline Replace Button + +Command: + +```bash +PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium +``` + +Failed test: + +```text +yjs collaboration example › preserves remote appends when an offline replace is undone before reconnect +``` + +Repeated command: + +```bash +PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium -g "preserves remote appends when an offline replace is undone before reconnect" +``` + +Repeated signature: + +```text +Locator: locator('#yjs-peer-b-editor-surface') +Expected substring: "Lin canonical snapshot." +Received string: "Hello world!" +playwright/integration/examples/yjs-collaboration.test.ts:920 +``` + +Trace artifact: + +```text +test-results/integration-examples-yjs-c-c09fd--is-undone-before-reconnect-chromium/trace.zip +``` + +Route: `slate-patch`. The command shape is valid, the focused grep reproduced, and the failure is behavior-level. + +## Stable Failure 2: Escape-Hatch Inventory Drift + +Command: + +```bash +bun check:full +``` + +Failed before integration-local: + +```text +bun test:release-discipline +packages/slate/test/escape-hatch-inventory-contract.ts +``` + +Repeated command: + +```bash +bun test ./packages/slate/test/escape-hatch-inventory-contract.ts --bail 1 +``` + +Repeated signature: + +```text +actual: + browser-proof-rows:primitive = 55 + browser-proof-rows:stale = 299 + slate-react-tests:bridge = 18 + slate-react-tests:stale = 1 +expected: + generated-package-output:stale = 21 + browser-proof-rows:primitive = 50 + browser-proof-rows:stale = 249 + slate-react-tests:bridge = 13 +``` + +Route: release-discipline patch. This is a stable project gate failure, not a Yjs collaboration behavior failure. + +## Not Reached + +`bun check:full` did not reach `bun test:integration-local` because `test:release-discipline` failed first. diff --git a/autoresearch.research/yjs-pr20/perf-evidence.md b/autoresearch.research/yjs-pr20/perf-evidence.md new file mode 100644 index 0000000000..63b0304878 --- /dev/null +++ b/autoresearch.research/yjs-pr20/perf-evidence.md @@ -0,0 +1,50 @@ +# Perf Evidence: yjs-pr20 Collaboration + +Date: 2026-06-02 +Target cwd: `/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2` + +## Summary + +| Item | Status | Evidence | Route | +| --- | --- | --- | --- | +| Real `@slate/yjs` perf benchmark | baseline | Added `scripts/benchmarks/core/current/yjs-collaboration.mjs`; it prints `METRIC` lines and writes `tmp/slate-yjs-collaboration-benchmark.json`. | `slate-ar-perf` | +| Multi-editor sync | pass | 4 peers, 100 blocks, 40 local text ops, p95 `16.56ms`; all peers converged. | none | +| Awareness updates | pass | 4 peers, 100 awareness selection updates, p95 `10.57ms`; every peer saw `peerCount - 1` remote cursors. | none | +| Reconnect sync | pass | Offline peer plus concurrent online edits, p95 `18.98ms`; all peers converged after reconnect. | none | +| Large-doc sync | pass | 4 peers, 1000 blocks, 120 local text ops, p95 `122.33ms`; all peers converged. | none | +| Focused package correctness | pass | `bun test ./packages/slate-yjs/test`: 106 pass, 0 fail across 16 files. | none | +| `@slate/yjs` typecheck | pass | `bun --filter @slate/yjs typecheck`: exited 0. | none | +| Fast repo gate | pass | `bun check`: lint, package/site/root typecheck, Bun tests, Slate React Vitest passed. | none | +| Focused Yjs browser correctness | checks_failed | Stable failure reproduced through Playwright webServer: `preserves remote appends when an offline replace is undone before reconnect`; peer B stayed `Hello world!`, expected `Lin canonical snapshot.` | `slate-patch` | + +## Benchmark Command + +```bash +bun ./scripts/benchmarks/core/current/yjs-collaboration.mjs +``` + +## Metrics + +```text +METRIC yjs_multi_editor_sync_p95_ms=16.56 +METRIC yjs_awareness_updates_p95_ms=10.57 +METRIC yjs_reconnect_p95_ms=18.98 +METRIC yjs_large_doc_sync_p95_ms=122.33 +METRIC yjs_collaboration_worst_p95_ms=122.33 +METRIC yjs_correctness_failures=0 +``` + +Primary metric: `yjs_collaboration_worst_p95_ms=122.33`. + +Baseline, latest, and best are the same because this packet only added the real measurement surface. No optimization was kept. + +## Correctness Gates + +```bash +bun test ./packages/slate-yjs/test +bun --filter @slate/yjs typecheck +bun check +PLAYWRIGHT_BASE_URL=http://localhost:3100 PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/yjs-collaboration.test.ts --project=chromium -g "preserves remote appends when an offline replace is undone before reconnect" +``` + +The Playwright failure matches `gate-evidence.md` and blocks promotion of perf changes. Route it to `slate-patch` before running optimization packets. diff --git a/autoresearch.research/yjs-pr20/plan.md b/autoresearch.research/yjs-pr20/plan.md new file mode 100644 index 0000000000..06ba8151ef --- /dev/null +++ b/autoresearch.research/yjs-pr20/plan.md @@ -0,0 +1,13 @@ +# Research Plan: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## Workstreams +- Project essence and audience +- Current implementation and architecture evidence +- High-impact improvement candidates +- Risks, constraints, and validation strategy + +## Sequencing +- Gather evidence first. +- Synthesize findings into `synthesis.md`. +- Convert actionable findings into `quality-gaps.md`. +- Iterate with the Codex Autoresearch skill until `quality_gap=0`. diff --git a/autoresearch.research/yjs-pr20/quality-gaps.md b/autoresearch.research/yjs-pr20/quality-gaps.md new file mode 100644 index 0000000000..751ab55de4 --- /dev/null +++ b/autoresearch.research/yjs-pr20/quality-gaps.md @@ -0,0 +1,26 @@ +# Quality Gaps: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +- [x] Project essence is accurate and source-backed. +- [x] Sources are logged with dates, claims, and confidence. +- [x] Synthesis separates high-impact changes from small QoL fixes. +- [x] Each high-impact recommendation is routed or rejected with evidence. +- [x] Correctness checks are scoped for this research-only pass. +- [x] Final handoff includes state evidence. + +## Accepted Routed Gaps + +| Gap | Route | Evidence | Validation | +| --- | --- | --- | --- | +| Provider lifecycle is too implicit. | `slate-plan` | `YjsExtensionOptions` takes `doc`/`awareness`; `connect`/`disconnect` are local flags; Lexical has provider lifecycle events. | Public API plan covering provider shape, status/sync/reload semantics, cleanup, and migration-free docs. | +| Remote cursor rendering is underpowered. | `slate-plan` | React package exposes range hooks only; y-prosemirror and old slate-yjs ship render/decorate helpers. | Plan a first-party React cursor decoration/rendering API with tests for caret, range, data, local-user filtering, blur cleanup, and field names. | +| Collaboration proof lacks a named release gate. | `slate-ar-gate` | Browser suite has the needed oracles but no single release gate definition. | Gate command bundle: `bun test ./packages/slate-yjs/test`, `bun --filter @slate/yjs typecheck`, and focused Playwright Yjs collaboration greps for reconnect, undo/redo, awareness, and selection. | +| Operation encoder exhaustiveness is not explicit enough. | `slate-patch` | `applySlateOperationToYjs` covers the current union but has no visible `never` assertion or operation coverage table. | Add failing-first contract, then an exhaustive guard that fails when Slate adds an operation without a Yjs decision. | +| Public examples are fixture-heavy and not provider-realistic. | `slate-plan` | The current demo hand-rolls local networking and undo group UI for deterministic proof. | Plan a copy-paste provider-backed example separate from the four-peer test matrix. | + +## Rejected Candidates + +| Candidate | Decision | Evidence | +| --- | --- | --- | +| Basic operation coverage is missing. | rejected | Current package tests cover each current operation family. | +| UndoManager private-stack use needs an immediate patch. | rejected for this round | The dependency is isolated, version-pinned, and covered by contract tests. | +| Performance route. | rejected for this round | No Yjs collaboration perf metric was measured or implicated. | diff --git a/autoresearch.research/yjs-pr20/sources.md b/autoresearch.research/yjs-pr20/sources.md new file mode 100644 index 0000000000..ebb2e9f8a1 --- /dev/null +++ b/autoresearch.research/yjs-pr20/sources.md @@ -0,0 +1,20 @@ +# Research Sources: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +| Source | Date Checked | Claim Supported | Confidence | +| --- | --- | --- | --- | +| `.tmp/slate-v2/packages/slate-yjs/src/core/types.ts:46-90` | 2026-06-01 | Public extension API is low-level: callers pass `doc`, `awareness`, field names, client id, and root name; runtime tx exposes `connect`, `disconnect`, `pause`, `resume`, `reconcile`, `undo`, `redo`, and cursor publishing. | high | +| `.tmp/slate-v2/packages/slate-yjs/src/core/controller.ts:94-180` | 2026-06-01 | Controller owns Y.Doc/root setup, awareness listener, Y.UndoManager, local-origin transaction export, selection publishing, no-op filtering, and remote import avoidance. | high | +| `.tmp/slate-v2/packages/slate-yjs/src/core/controller.ts:222-242` | 2026-06-01 | `connect` and `disconnect` are local binding flags; they do not call provider lifecycle methods. | high | +| `.tmp/slate-v2/packages/slate-yjs/src/core/operations.ts:272-536` | 2026-06-01 | Operation encoder covers current Slate operation types, with direct text/node encoders and traceable virtual/fallback paths for identity-sensitive structural edits. | high | +| `.tmp/slate-v2/packages/slate-yjs/src/react/index.ts:1-41` | 2026-06-01 | React surface only exposes external-store awareness revision and remote cursor range hooks; it does not provide decorate/render helpers for visible remote selections. | high | +| `.tmp/slate-v2/packages/slate-yjs/src/core/undo-manager-adapter.ts:1-68` | 2026-06-01 | Split-history undo/redo uses Yjs private `undoStack` / `redoStack` behind one adapter pinned to Yjs 13.6.30. | high | +| `.tmp/slate-v2/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts:11-58` and `.tmp/slate-v2/packages/slate-yjs/test/package-config-contract.spec.ts:11-26` | 2026-06-01 | The private UndoManager dependency has explicit package-level contract tests. | high | +| `.tmp/slate-v2/packages/slate-yjs/test/*.spec.ts` | 2026-06-01 | Package tests cover awareness, selection, undo-manager, package config, and operation families including simple operations, remove, merge, move, split, wrap, unwrap, lift, insert/delete/replace fragment, and set node. | high | +| `.tmp/slate-v2/playwright/integration/examples/yjs-collaboration.test.ts:418-1902` | 2026-06-01 | Browser example suite has 40 visible collaboration tests covering operation controls, marks, awareness, disconnect/reconnect, stale undo, keyboard vs toolbar undo/redo, multi-peer convergence, layout, selection, and offline structural conflicts. | high | +| `.tmp/slate-v2/site/examples/ts/yjs-collaboration.tsx:240-353` and `:1458-1818` | 2026-06-01 | Demo creates four local Y.Docs, hand-rolls a simulated provider/network, tracks local undo groups outside the package, and displays remote cursor state as text rather than rendered ranges. | high | +| `../lexical/packages/lexical-yjs/src/index.ts:50-70` | 2026-06-01 | Lexical exposes a provider-shaped API with awareness plus `connect`, `disconnect`, `sync`, `status`, `update`, and `reload` events. | medium | +| `../y-prosemirror/src/cursor-plugin.js:156-297` | 2026-06-01 | y-prosemirror ships cursor rendering as a first-class plugin with configurable filtering, cursor builder, selection builder, selection reader, and field name. | high | +| `../y-prosemirror/src/undo-plugin.js:14-227` | 2026-06-01 | y-prosemirror captures selections as relative-position bookmarks, stores them on Yjs undo stack items, restores on pop, and updates UndoManager tracked origins for non-history transactions. | high | +| `../slate-yjs/packages/react/src/hooks/useDecorateRemoteCursors.ts:1-125` | 2026-06-01 | The older slate-yjs React package provides leaf decoration helpers, caret/range decoration keys, and utilities to read remote cursor/caret payloads from leaves. | high | +| `autoresearch.research/yjs-pr20/quality-gaps.md` before this update | 2026-06-01 | Initial research checklist measured `quality_gap=6`, all six generic research checklist items open. | high | +| `gap-candidates --cwd .tmp/slate-v2 --research-slug yjs-pr20` before this update | 2026-06-01 | One medium-confidence candidate survived filtering: keep `quality-gaps.md` aligned with current synthesis. | high | diff --git a/autoresearch.research/yjs-pr20/synthesis.md b/autoresearch.research/yjs-pr20/synthesis.md new file mode 100644 index 0000000000..7f51df6b54 --- /dev/null +++ b/autoresearch.research/yjs-pr20/synthesis.md @@ -0,0 +1,51 @@ +# Research Synthesis: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## Project Essence + +`@slate/yjs` is a first-party Slate v2 extension that binds editor commits to a Y.XmlElement root. It exports local Slate operations into Yjs transactions, imports remote Yjs changes back into Slate with history skipped, publishes awareness selections as Yjs relative positions, and routes undo/redo through a local Y.UndoManager. + +The current package is not a scaffold. It has operation-family contract tests and a four-peer browser example with heavy offline/reconnect coverage. The sharp gaps are API/DX and proof policy, not basic collaboration existence. + +## High-Impact Findings + +### Strong Current Coverage + +- Current Slate operation families are represented in `applySlateOperationToYjs(...)`: text insert/remove, node insert/remove/split/merge/move/set, replace children, replace fragment, and selection skip. +- The package test matrix covers operation families plus awareness, selection, package config, and Yjs UndoManager private-stack guard tests. +- The browser example has 40 tests, including keyboard history, toolbar history, stale undo, reconnect, offline structural conflicts, multi-peer convergence, layout synchronization, and selection deletion. +- Previous hard bugs were already converted into proof: split/merge history, same-parent move, no-op replace_fragment, wrapped/fragment stale paths, cursor publishing after keyboard undo, and concurrent text preservation. + +### Accepted Gaps + +1. Provider lifecycle is too implicit. + Evidence: `YjsExtensionOptions` accepts `doc` and `awareness`, while `tx.yjs.connect()` / `disconnect()` only flip local state. Lexical exposes a provider-shaped contract with connect/disconnect and sync/status/update/reload events. + Impact: app authors must hand-wire provider status, reconnect, and awareness ownership outside the package. The demo does that manually, which is fine for a fixture and weak for a public package. + Route: `slate-plan`. + +2. Remote cursor rendering is underpowered. + Evidence: `@slate/yjs/react` exposes cursor range hooks only. y-prosemirror ships cursor rendering builders and old slate-yjs exposes decoration helpers. + Impact: every app has to rediscover range-to-decoration rendering, caret direction, field naming, local-user filtering, and blur cleanup. That is bad DX and a good way to ship subtly broken awareness. + Route: `slate-plan`. + +3. Collaboration proof lacks a named release gate. + Evidence: the browser suite is rich, but the accepted proof shape is encoded as many individual tests, not as a documented release-quality gate that says which package tests, typecheck, and focused browser grep must pass before claims land. + Impact: future work can pass package tests while skipping keyboard/selection/offline browser proof. That is where most nasty bugs live. + Route: `slate-ar-gate`. + +4. Operation encoder exhaustiveness is not explicit enough. + Evidence: the encoder covers the current union, but there is no visible compile-time `never` assertion or route table that fails loudly when Slate adds an operation type. + Impact: future operation additions can become an accidental no-op or unreviewed fallback path. This is a correctness guard gap, not a known user bug. + Route: `slate-patch`. + +5. Public examples are fixture-heavy and not provider-realistic. + Evidence: the current demo hand-rolls local networking and undo group UI for deterministic proof. + Impact: users need a minimal provider-backed example showing `createYjsExtension`, awareness, remote cursor rendering, connection status, reconnect, cleanup, and history keys without the test harness machinery. + Route: `slate-plan`. + +## Quality-Gap Translation + +The accepted routes are recorded in `quality-gaps.md`: `slate-plan` owns provider lifecycle API, React cursor rendering API, and the provider-backed example; `slate-ar-gate` owns the named release proof bundle; `slate-patch` owns the operation encoder exhaustiveness guard. No `slate-ar-perf` route is accepted in this round because no perf metric or trace implicated Yjs collaboration. + +## Confidence And Gaps + +Confidence is high for the API/DX and gate findings because live source and local comparative repos back them. Confidence is medium for the exact provider lifecycle API shape; provider ecosystems differ, so that belongs in planning before implementation. Basic operation coverage is rejected as a current gap, UndoManager private-stack use is rejected as an immediate patch by itself, and performance is rejected because this round produced no metric evidence. diff --git a/autoresearch.research/yjs-pr20/tasks.md b/autoresearch.research/yjs-pr20/tasks.md new file mode 100644 index 0000000000..d7e1e2b036 --- /dev/null +++ b/autoresearch.research/yjs-pr20/tasks.md @@ -0,0 +1,24 @@ +# Research Tasks: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. + +## queued +- Run routed follow-ups: `slate-plan` for provider lifecycle/cursor rendering/provider-backed example. +- Run routed follow-up: `slate-ar-gate` for named Yjs release proof. +- Run routed follow-up: `slate-patch` for operation encoder exhaustiveness. +- Run routed follow-up: `slate-patch` for stable Yjs offline replace Playwright failure. +- Run routed follow-up: release-discipline patch for escape-hatch inventory drift. +- Run routed follow-up: `slate-ar-perf` optimization packet after the stable Yjs offline replace Playwright failure is fixed. + +## in_progress +- None. + +## done +- Scratchpad initialized. +- Captured live @slate/yjs package essence from source. +- Logged repo and comparative sources with dates, claims, and confidence. +- Separated accepted gaps from rejected candidates. +- Routed accepted gaps to `slate-plan`, `slate-ar-gate`, or `slate-patch`. +- Logged full proof gate evidence in `gate-evidence.md`. +- Added real `@slate/yjs` collaboration perf benchmark and logged baseline evidence in `perf-evidence.md`. + +## blockers +- None. diff --git a/autoresearch.sh b/autoresearch.sh new file mode 100755 index 0000000000..15779d5e74 --- /dev/null +++ b/autoresearch.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +"/Users/felixfeng/.nvm/versions/node/v24.11.1/bin/node" "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" quality-gap --cwd . --research-slug "yjs-pr20" diff --git a/docs/plans/2026-06-01-yjs-command-helper-bug-reduction.md b/docs/plans/2026-06-01-yjs-command-helper-bug-reduction.md new file mode 100644 index 0000000000..ce39b2b71e --- /dev/null +++ b/docs/plans/2026-06-01-yjs-command-helper-bug-reduction.md @@ -0,0 +1,38 @@ +# Yjs Command Helper Bug Reduction + +Goal: reduce obvious browser-visible collaboration demo command bugs. + +## Scope + +- Keep the fix focused on command/helper behavior in `site/examples/ts/yjs-collaboration.tsx`. +- Use Playwright regressions for observable peer convergence and page errors. +- Preserve existing Yjs/history semantics; no snapshot fallback. + +## Progress + +- [x] Loaded codex-autoresearch, task, debug, testing, tdd, learnings, and planning context. +- [x] Read current Yjs collaboration helper code and prior structural wrap/fragment/history learnings. +- [x] Add failing Playwright coverage for obvious command-helper bugs. +- [x] Fix command helpers that still hard-code stale text paths. +- [x] Add failing Playwright coverage for disconnected Replace after structured first-block edits. +- [x] Fix disconnected Replace to replay a root `replace_fragment` operation instead of hand-written `[0, 0]` text edits. +- [x] Add failing package and Playwright coverage for splitting inside virtual wrapped blocks. +- [x] Fix virtual wrapper child-slot reads so split-created raw siblings remain visible. +- [x] Fix Split then Merge button DOM sync for the initiating peer. +- [x] Fix custom split undo to delete the right split element by visible slot after a prior virtual merge. +- [x] Re-run the 96-pair connected command matrix; no findings. +- [x] Run focused Playwright and relevant repo checks. +- [x] Capture the reusable stale-command-path learning. + +## Findings + +- The example already has generic text-entry helpers, but several command buttons still target `[0, 0]`. +- Those hard-coded paths are unsafe after commands create nested wrappers or multiple text leaves. +- The red cases were fragment after fragment, wrap then text/delete/back controls, and append/back/fragment/back. +- Disconnected Replace still used manual `[0, 0]` `remove_text`/`insert_text`. It failed after Wrap with a non-leaf path error and after Fragment with stale offset errors. +- Splitting inside a virtual wrapped block hit the same raw-vs-visible slot bug as earlier structural moves. Inserting the right split node by raw index could throw `Length exceeded!`; after that insert path was fixed, the right split node was still hidden because virtual-child readers returned only the virtual source and ignored later raw siblings. +- Split then Merge produced a model/Yjs-converged value but left the initiating peer's DOM stale until another render. The demo button now bumps its render epoch after Merge. +- Merge then Split then Undo exposed the matching delete-side bug: custom split undo found the right split element by visible path but deleted `parent.delete(index, 1)` by raw index. A hidden merge source before the right split node made Undo delete the hidden node and leave the right split visible. +- The final connected command pair scan covered 96 pairs and returned zero page-error or convergence findings. +- Verification: focused 8-test Playwright structure subset passed, `bun typecheck:site`, `bun lint:fix`, `bun typecheck:root`, `bun lint`, and `bun check` passed. +- Learning captured in `docs/solutions/ui-bugs/yjs-demo-command-stale-text-paths-2026-06-01.md`. diff --git a/docs/plans/2026-06-01-yjs-history-undo-redo-bugs.md b/docs/plans/2026-06-01-yjs-history-undo-redo-bugs.md new file mode 100644 index 0000000000..fe64392f2c --- /dev/null +++ b/docs/plans/2026-06-01-yjs-history-undo-redo-bugs.md @@ -0,0 +1,48 @@ +# Yjs History Undo Redo Bugs + +Goal: TDD-fix two confirmed Slate/Yjs history bugs. + +## Bugs + +1. Merge -> Split -> Undo throws `Cannot undo split_node with a non-text right-side element yet.` and does not revert. +2. Multi-paragraph keyboard input Undo/Redo loses content and can leave stale enabled history controls. + +## Constraints + +- Red Playwright coverage first, then implementation. +- Prefer fixing the history/Yjs ownership layer over disabling UI buttons around failures. +- Preserve Yjs identity; no snapshot fallback. +- Browser verification matters because selection/history state is user-visible. + +## Progress + +- [x] Loaded task/debug/tdd/testing/learnings/planning context. +- [x] Read prior split/merge/history solution docs. +- [x] Inspected current split history and demo history grouping code. +- [x] Added Playwright regressions for Merge -> Split -> Undo and empty-doc multi-paragraph redo. +- [x] Added package split history regressions for right-side redo identity and merge-followed-by-split undo. +- [x] Fixed split history replay so custom merge undo only runs when redo stack has no dependent later item. +- [x] Stopped demo history from recording empty commits as undoable groups. +- [x] Ran focused package split/adapter tests and focused Playwright regressions. +- [x] Run package typecheck and lint. +- [x] Captured reusable learning in `docs/solutions/logic-errors/yjs-split-history-dependent-redo-2026-06-01.md`. +- [ ] Reopened keyboard Redo empty-doc regression after finding button-only coverage missed the browser shortcut path. +- [x] Reproduced keyboard Redo failure in Playwright: document-level keydown sees `Ctrl+Shift+Z`, but the demo history handler is not reached after empty-doc undo. +- [x] Added package regression proving no-op `replace_fragment` must not touch Yjs redo history. +- [x] Fixed Yjs controller filtering so no-op structural replacements do not create Yjs transactions or traces. +- [x] Fixed keyboard Redo shortcut interception at the editor-surface capture boundary. +- [x] Fixed history selection normalization so a cursor at document end moves to the new document end after redo. +- [x] Verified the reopened keyboard Redo test with text, local selection, remote cursor, and disabled Redo assertions. +- [x] Ran `bun test packages/slate-yjs/test` with 104 passing tests. +- [x] Ran focused Playwright history tests with 4 passing tests. +- [x] Ran `bun --filter @slate/yjs typecheck`, `bun typecheck:site`, and `bun lint`. +- [x] Updated the existing split-history solution doc with the no-op `replace_fragment` and cursor-end findings. + +## Findings + +- Browser text input emits granular `insert_text`, `split_node`, and `insert_text` only when the caret is placed through the real editor surface. The earlier empty `replace_fragment` trace was a bad test setup path. +- The custom split undo path deleted the split-created right paragraph. If a later local text edit in that right paragraph had already been undone, redo still referenced the original Yjs node and became a no-op. +- Native Yjs replay preserves that right-side redo identity, but the custom split undo is still needed for the existing concurrent remote append contract. The fix gates custom undo/redo to the single split item case. +- Keyboard Redo still failed because after undoing to the empty paragraph, Slate React could consume the history shortcut before the demo's `Editable onKeyDown` ran. Collaboration history needs to intercept history hotkeys at the editor-surface capture boundary. +- Keyboard Undo can also trigger no-op `replace_fragment` repairs where `children` and `newChildren` are identical. Those operations must not reach Yjs, because even a no-op structural replace can clear redo history. +- Selection repair must distinguish text-leaf end from document end. Redo from `['a']` to `['a', 'b']` should move the cursor to `[1, 0]@1`, not keep it at `[0, 0]@1`. diff --git a/docs/solutions/logic-errors/yjs-split-history-dependent-redo-2026-06-01.md b/docs/solutions/logic-errors/yjs-split-history-dependent-redo-2026-06-01.md new file mode 100644 index 0000000000..fe720d20e2 --- /dev/null +++ b/docs/solutions/logic-errors/yjs-split-history-dependent-redo-2026-06-01.md @@ -0,0 +1,110 @@ +--- +title: Gate custom Yjs split history replay when dependent redo items exist +date: 2026-06-01 +last_updated: 2026-06-01 +category: logic-errors +module: slate-yjs +problem_type: logic_error +component: tooling +symptoms: + - Merge then split then undo throws a split history error and does not revert + - Keyboard Redo after undoing multi-paragraph input to an empty document no-ops + - Undo remains enabled after the visible document returns to an empty paragraph + - Cursor stays at the old paragraph after Redo restores a later paragraph + - No-op command buttons can enable Undo and later create unexpected blocks +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [slate-yjs, yjs, undo-redo, split-node, history, keyboard, command, no-op] +--- + +# Gate custom Yjs split history replay when dependent redo items exist + +## Problem +Split history had two valid requirements that conflicted. Custom split replay is needed for existing concurrent-edit behavior, but it must not run when later undone edits still point at the split-created right-side Yjs node. + +## Symptoms +- Merge, split, then undo could throw `Cannot undo split_node with a non-text right-side element yet.` and leave the document split. +- Typing `a`, pressing Enter, typing `b`, undoing to an empty paragraph, then redoing could lose `b`. +- The example history controls could stay enabled after the visible document had no local undoable text left. +- Keyboard Redo could consume the redo groups without restoring text, while toolbar Redo appeared healthier. +- After Redo restored `a / b`, the local and remote cursor could remain at the end of `a`. +- Command buttons like Remove, Merge, Unwrap, Lift, and Unset could be visually no-op on a plain single paragraph while still enabling Undo. Undo could then create `block 2` or a `blockquote`. + +## What Didn't Work +- Replaying inverse Slate operations for every split looked direct but deleted the right-side Yjs node and recreated a different one on redo. +- Falling back to native Yjs replay for every split preserved local redo identity but regressed the existing offline split plus concurrent remote append contract. +- Convergence-only checks missed the bug because the failure was in later redo stack behavior, not the immediate merged document text. +- Filtering only the demo `undoGroups`/`redoGroups` was too shallow. Browser keyboard Undo can emit no-op `replace_fragment` repairs where `children` and `newChildren` are identical; if those operations reach Yjs, they can still clear the underlying redo stack. +- Repairing selection to the same text path was wrong when the previous cursor was at document end and Redo added a later paragraph. +- Making demo controls always exercise an operation by inserting a temporary second paragraph or wrapping a normal paragraph was wrong. The final document could look unchanged, but the intermediate insert/wrap became real Yjs and UI history. + +## Solution +Keep the custom split replay path, but only use it when the split item is isolated on the redo side. If another local edit was undone first, native Yjs replay keeps dependent item identities valid. + +```ts +private undoSplit() { + const undo = this.peekSplit(this.undoManagerAdapter.peekUndo()) + + if (!undo || this.undoManagerAdapter.redoDepth() > 0) { + return false + } + + // custom split merge replay +} + +private redoSplit() { + const redo = this.peekSplit(this.undoManagerAdapter.peekRedo()) + + if (!redo || this.undoManagerAdapter.redoDepth() > 1) { + return false + } + + // custom split replay +} +``` + +Also make the custom merge path read all visible text under the right-side element instead of assuming a single direct text child, and keep the demo history stack from recording empty commits as undoable groups. + +No-op structural replacements are filtered before the Yjs transaction starts: + +```ts +const operations = commit.operations.filter( + (operation) => + operation.type !== 'set_selection' && + !isNoopSlateOperationForYjs(operation) +) +``` + +The same guard lives in `applySlateOperationToYjs` so direct operation replay does not trace or write an identity-risk replacement for unchanged children. + +The demo catches collaboration history shortcuts at the editor-surface capture boundary. That keeps `Cmd/Ctrl+Z` and `Cmd/Ctrl+Shift+Z` on the Yjs history path even when Slate React would otherwise consume the event before the example's `Editable onKeyDown` handler. + +Selection repair treats document-end separately from text-leaf-end. If Redo grows `['a']` into `['a', 'b']`, the cursor moves to `[1, 0]@1`, not `[0, 0]@1`. + +No-op command helpers return before editing when their preconditions are missing. Remove and Merge require a second paragraph. Unwrap and Lift require an existing quote wrapper. Unset requires the target property to exist. + +## Why This Works +Yjs redo items can target concrete shared types created by an earlier split. Deleting that right-side element during custom split undo invalidates later redo items that still reference it. Native Yjs undo/redo preserves the internal references for that dependent case, while the isolated custom path keeps the concurrent remote append behavior already covered by package tests. + +Ignoring empty commits keeps the demo's `undoGroups` and `redoGroups` aligned with real Yjs undo items, so the buttons cannot spend fake history entries. + +Ignoring no-op structural replacements before Yjs transaction creation keeps the underlying `Y.UndoManager` redo stack intact. This is stricter than UI-stack filtering: a no-op Slate replacement can still be a tracked Yjs transaction if it reaches the encoder. + +Capturing keyboard history at the surface makes keyboard and toolbar history use the same collaboration protocol. Selection normalization then publishes the repaired cursor through awareness, so remote cursor UI tracks undo and redo, not just document text. + +Command helpers must not synthesize setup content inside an undoable command. Even if the command restores the same visible text before the network sync, the intermediate Yjs transactions remain undoable and can be replayed later. + +## Prevention +- Test split history with a later edit inside the split-created right paragraph, then undo and redo the whole sequence. +- Test merge followed by split followed by undo, because virtual merge children make the right side more complex than one direct text node. +- Assert history button disabled state after undoing to an empty paragraph; stale enabled controls usually mean the UI stack drifted from the Yjs stack. +- Add package tests for no-op structural replacements between Undo and Redo. They should leave Yjs trace empty and preserve redo. +- Browser tests for history bugs should assert text, local selection, remote cursor awareness, and disabled button state for keyboard and toolbar paths separately. +- Browser tests for command no-ops should assert the command leaves Undo disabled, not merely that visible text is unchanged. +- Treat custom Yjs replay as unsafe whenever redo items can still reference nodes the custom path would delete. + +## Related Issues +- `docs/solutions/logic-errors/yjs-split-history-empty-leaf-reconnect-2026-05-26.md` +- `docs/solutions/logic-errors/yjs-merge-read-virtual-text-leaves-2026-05-27.md` +- `docs/solutions/ui-bugs/yjs-keyboard-undo-cursor-grouping-2026-05-31.md` diff --git a/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md b/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md index ee793c9d13..5254a32ecc 100644 --- a/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md +++ b/docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md @@ -1,7 +1,7 @@ --- title: Preserve Yjs identity through structural wrap, unwrap, and fragment edits date: 2026-05-28 -last_updated: 2026-05-29 +last_updated: 2026-06-01 category: logic-errors module: slate-yjs problem_type: logic_error @@ -11,6 +11,8 @@ symptoms: - Offline unwrap_node drops a remote insert inside the unwrapped node after reconnect. - Offline insert fragment drops a remote append at the same text position after reconnect. - Offline merge undo can leave the initiating editor split from the shared Yjs value. + - Splitting inside a wrapped virtual child can throw `Length exceeded!` or hide the right split node. + - Undoing a split after a prior virtual merge can leave the right split node visible. root_cause: logic_error resolution_type: code_fix severity: high @@ -27,6 +29,12 @@ Some offline structural edits were encoded by cloning visible Yjs nodes and hidi - B goes offline, unwraps a virtual-wrapped block, A inserts `!` inside the wrapped block, then B reconnects. Broken result: peers can see an empty wrapper or lose the remote insert; expected one unwrapped paragraph reading `alpha!`. - B goes offline, inserts `Lin fragment` at the end of `alpha`, A appends ` Ada`, then B reconnects. Broken result: `alphaLin fragment`; expected `alpha AdaLin fragment`. - B goes offline, merges `alpha / beta`, A appends to `beta`, then B reconnects and undoes. Broken result: B can stay at `alphabeta / empty` while other peers read `alpha / beta`. +- A wraps `alpha`, then splits inside the wrapped text. Broken behavior either + throws `Length exceeded!` while inserting the right split node or reads only + the left side from Yjs afterward. +- A merges two paragraphs, splits the merged paragraph, then undoes. Broken + behavior merges the text back into the left paragraph but leaves the right + split paragraph visible because Undo deleted the hidden merge source instead. ## What Didn't Work - Treating `wrap_node` as a normal `move_node` clone lost remote edits because the visible clone and hidden original were independent Yjs types. @@ -69,11 +77,43 @@ sharedText.insert(insertOffset, text, getNodeAttributes(leaf)) For historic commits, after exporting the Slate history replay to Yjs, read the canonical shared value and replace the local editor value when they differ. +Virtual wrapper children need one extra slot rule: the wrapper's visible child +list is the referenced virtual source followed by any raw visible children that +were inserted later by structural operations such as `split_node`. + +```ts +if (virtualChild) { + return [{ node: virtualChild, rawIndex: -1 }, ...rawSlots] +} +``` + +Structural encoders should also insert by visible child slot. `insert_node` and +both element/text `split_node` paths route through `insertYjsChild(...)` instead +of calling `parent.insert(index, ...)` directly. A virtual wrapper may have zero +raw children while exposing one visible virtual child, so raw insertion index +`1` is invalid even though visible index `1` is exactly where the right split +node belongs. + +The same coordinate split applies to deletions. Custom split undo resolves the +right split element through a visible path, so removing it must call +`removeYjsChild(root, parent, index)`. Calling `parent.delete(index, 1)` uses a +raw Yjs index and can delete an earlier hidden merge source instead of the +visible right split node. + ## Why This Works Yjs can rebase concurrent edits when both peers edit the same shared type. Clone-and-hide is acceptable for some move operations, but not when the hidden source still receives meaningful concurrent text. A virtual wrapper child keeps the original shared node alive for both local wrapped edits and remote updates. Text diffing keeps fragment insertion in the same `Y.XmlText`, so same-offset inserts order by Yjs conflict rules instead of disappearing behind `slate:deleted`. The history fix handles the remaining mismatch layer: Slate's local undo replay can be structurally stale, while Yjs has already produced the correct collaborative value. Replacing local Slate state from Yjs after a historic export keeps the initiating peer converged. +The virtual slot fix keeps wrapper reads faithful after later structural edits. +The virtual child is still first, preserving the original shared type identity, +and split-created raw siblings remain visible instead of becoming hidden Yjs +children that only exist in the underlying XML tree. + +The split-undo deletion fix keeps history replay on the same coordinate system +as path lookup. If a previous merge left hidden raw nodes in the parent, visible +index `1` and raw index `1` can refer to different Yjs children. + ## Prevention - Add package-level tests for each structural encoder that can hide or clone a Yjs container. - Characterize public composed transforms by their emitted Slate operations before encoding them. In the current v2 API, `wrapNodes` emits `insert_node` then `move_node`; `unwrapNodes` emits `move_node` then `remove_node`. @@ -82,6 +122,9 @@ The history fix handles the remaining mismatch layer: Slate's local undo replay - For browser examples, assert final peer text only; do not add style or disabled-state assertions to collaboration e2e tests. - Treat Potion as a parity oracle only after confirming the same operation shape. Move/down clone loss still matches Potion and should stay out of this fix. - When a fix needs conflict resolution, preserve the original Yjs shared type or add an explicit virtual reference back to it. +- When a Yjs element exposes virtual children, treat raw child indexes and + visible Slate indexes as separate coordinate systems. Reads, path lookup, and + insertions/deletions must all use the visible-slot helpers. ## Related Issues - `docs/solutions/logic-errors/yjs-offline-replace-undo-concurrent-append-2026-05-25.md` diff --git a/docs/solutions/ui-bugs/yjs-demo-command-stale-text-paths-2026-06-01.md b/docs/solutions/ui-bugs/yjs-demo-command-stale-text-paths-2026-06-01.md new file mode 100644 index 0000000000..aae3b137e2 --- /dev/null +++ b/docs/solutions/ui-bugs/yjs-demo-command-stale-text-paths-2026-06-01.md @@ -0,0 +1,109 @@ +--- +title: Resolve Yjs demo commands against current text leaves +date: 2026-06-01 +category: ui-bugs +module: slate-yjs +problem_type: ui_bug +component: tooling +symptoms: + - Fragment after Fragment leaves the second peer edit invisible. + - Wrap followed by Insert, Delete, or Back can no-op because `[0, 0]` is no longer a text path. + - Append, Back, Fragment, Back leaves the final Back as a no-op. + - Disconnected Replace after Wrap or Fragment throws stale path or stale offset errors. +root_cause: logic_error +resolution_type: code_fix +severity: medium +tags: [slate-yjs, yjs, demo-controls, stale-paths, playwright] +--- + +# Resolve Yjs demo commands against current text leaves + +## Problem +The Yjs collaboration demo command buttons cached the assumption that the first +editable text leaf always lives at `[0, 0]`. Structural and fragment commands +can wrap the first block or append new text leaves, so later controls operated +against stale paths and offsets. + +## Symptoms +- B clicks Fragment, D clicks Fragment, and D's fragment does not appear. +- C clicks Wrap, then B clicks Insert !, and the document stays unchanged. +- C clicks Wrap, then peers click Delete or Back, and the controls do nothing. +- B clicks Append, D clicks Back, B clicks Fragment, D clicks Back, and the + second Back does not remove the last fragment character. +- B disconnects, clicks Wrap or Fragment, clicks Replace, and reconnects. Broken + behavior either throws a non-leaf `[0, 0]` path error or tries to delete text + at an offset longer than the current leaf. + +## What Didn't Work +- Checking only offline reconnect cases missed connected command chains. +- Looking only for page errors was too weak; some stale-path commands no-op + without throwing. +- Fixing individual buttons would leave the same `[0, 0]` bug in the next + control that edits text after a structural command. +- Reusing manual text operations for disconnected Replace kept the stale path + problem even after connected Insert/Delete/Back controls resolved fresh leaves. + +## Solution +Resolve the current first or last text leaf from the live Slate value before +each command mutates text or sets a synthetic selection. + +```ts +const getFirstBlockTextEntry = ( + editor: CustomEditor, + position: 'first' | 'last' +) => { + const [block] = getEditorValue(editor) + + if (!block) { + return null + } + + return position === 'first' + ? findFirstTextEntryInNode(block, [0]) + : findLastTextEntryInNode(block, [0]) +} +``` + +Use the resolved `entry.path` and `entry.text.length` for Append, Insert, +Fragment, Delete, Back, Bold selection, and Select. The Split control also reads +the current text leaf and parent element before replaying `split_node`, instead +of replaying a hard-coded text path and block path. + +For Replace, avoid manufacturing text-level operations entirely. A document +replacement is a root-fragment operation: + +```ts +const operation: Operation = { + children: value, + newChildren: [paragraph(peer.replacementText)], + path: [], + root: 'main', + type: 'replace_fragment', +} +``` + +That keeps disconnected Replace aligned with the same operation shape a real +editor command would emit, regardless of whether the first visible block is +plain, wrapped, or contains multiple text leaves. + +## Why This Works +The demo control panel is intentionally a public-command matrix. Those commands +must behave like real user controls after previous commands change the document +shape. Resolving the text leaf at command time keeps synthetic selections and +text operations aligned with the current editor tree, whether the first text is +directly under a paragraph or nested under a wrapper. + +## Prevention +- Browser tests for command matrices should include connected command chains, + not only offline reconnect scenarios. +- Assert both final peer text and `pageerror` output. No-op command failures can + be silent. +- Avoid hard-coded Slate text paths in demo controls unless the same helper just + created that exact document shape. +- Prefer operation-level document commands over hand-built text edits when the + intended user action replaces a whole fragment. + +## Related Issues +- `docs/solutions/logic-errors/yjs-structural-wrap-fragment-parity-2026-05-28.md` +- `docs/solutions/ui-bugs/slate-react-structural-text-dom-sync-2026-05-28.md` +- `docs/solutions/logic-errors/yjs-split-history-dependent-redo-2026-06-01.md` diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts index a70d2c8469..fcaa51e11c 100644 --- a/packages/slate-yjs/src/core/controller.ts +++ b/packages/slate-yjs/src/core/controller.ts @@ -16,15 +16,19 @@ import { yjsAwarenessSelectionsEqual, } from './awareness' import { - getYjsChildren, getYjsLength, getYjsNode, getYjsParent, getYjsTextContent, + getYjsVisibleChildren, readSlateValueFromYjs, + removeYjsChild, replaceYjsChildren, } from './document' -import { applySlateOperationToYjs } from './operations' +import { + applySlateOperationToYjs, + isNoopSlateOperationForYjs, +} from './operations' import type { YjsAwarenessChange, YjsAwarenessLike, @@ -146,7 +150,9 @@ export class YjsController { } const operations = commit.operations.filter( - (operation) => operation.type !== 'set_selection' + (operation) => + operation.type !== 'set_selection' && + !isNoopSlateOperationForYjs(operation) ) if (operations.length === 0) { @@ -446,7 +452,9 @@ export class YjsController { private redoSplit() { const redo = this.peekSplit(this.undoManagerAdapter.peekRedo()) - if (!redo) { + // Later redo items may still target the original right-side Yjs node. + // Let Yjs replay those split items natively so their identities survive. + if (!redo || this.undoManagerAdapter.redoDepth() > 1) { return false } @@ -497,7 +505,9 @@ export class YjsController { private undoSplit() { const undo = this.peekSplit(this.undoManagerAdapter.peekUndo()) - if (!undo) { + // If another local edit was undone first, it can depend on the split-created + // right-side node. Native Yjs undo keeps that node redoable. + if (!undo || this.undoManagerAdapter.redoDepth() > 0) { return false } @@ -518,8 +528,8 @@ export class YjsController { ) } - rightText = appendElementText(leftText, rightElement) - parent.delete(index, 1) + rightText = appendElementText(this.root, leftText, rightElement) + removeYjsChild(this.root, parent, index) }, this.historyOrigin) undo.splitHistory.rightText = rightText @@ -578,19 +588,11 @@ export class YjsController { } } -const appendElementText = (target: Y.XmlText, element: Y.XmlElement) => { - const children = getYjsChildren(element) - - if (children.length !== 1 || !(children[0] instanceof Y.XmlText)) { - throw new Error( - 'Cannot undo split_node with a non-text right-side element yet.' - ) - } - +const appendTextContent = (target: Y.XmlText, source: Y.XmlText) => { let offset = getYjsLength(target) let insertedText = '' - for (const delta of children[0].toDelta()) { + for (const delta of source.toDelta()) { if (typeof delta.insert !== 'string' || delta.insert.length === 0) { continue } @@ -603,6 +605,24 @@ const appendElementText = (target: Y.XmlText, element: Y.XmlElement) => { return insertedText } +const appendElementText = ( + root: Y.XmlElement, + target: Y.XmlText, + element: Y.XmlElement +) => { + let insertedText = '' + + for (const child of getYjsVisibleChildren(root, element)) { + if (child instanceof Y.XmlText) { + insertedText += appendTextContent(target, child) + } else { + insertedText += appendElementText(root, target, child) + } + } + + return insertedText +} + const isSplitHistory = (value: unknown): value is SplitHistory => typeof value === 'object' && value !== null && diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts index ad64e84ac5..664cbc2e72 100644 --- a/packages/slate-yjs/src/core/document.ts +++ b/packages/slate-yjs/src/core/document.ts @@ -66,15 +66,7 @@ const getVirtualChild = (root: Y.XmlElement, node: Y.XmlElement) => { } const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { - if (!isVirtualPlaceholder(node)) { - const virtualChild = getVirtualChild(root, node) - - if (virtualChild) { - return [{ node: virtualChild, rawIndex: -1 }] - } - } - - return getRawYjsChildren(node).flatMap((child, rawIndex) => { + const rawSlots = getRawYjsChildren(node).flatMap((child, rawIndex) => { if (isHiddenYjsNode(child)) { return [] } @@ -87,6 +79,16 @@ const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { return [{ node: child, rawIndex }] }) + + if (!isVirtualPlaceholder(node)) { + const virtualChild = getVirtualChild(root, node) + + if (virtualChild) { + return [{ node: virtualChild, rawIndex: -1 }, ...rawSlots] + } + } + + return rawSlots } export const getYjsChildren = (node: Y.XmlElement) => diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index 1ade13b053..2d23238c5d 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -1,4 +1,4 @@ -import type { Operation } from 'slate' +import type { Descendant, Operation } from 'slate' import * as Y from 'yjs' import { @@ -22,7 +22,22 @@ import type { YjsTraceEntry } from './types' const SLATE_TYPE_ATTRIBUTE = 'slate:type' -type ReplaceFragmentOperation = Extract +type SlateElementLike = { + children: readonly Descendant[] +} & Record + +const areJsonEqual = (left: unknown, right: unknown) => + JSON.stringify(left) === JSON.stringify(right) + +export const isNoopSlateOperationForYjs = (operation: Operation) => { + switch (operation.type) { + case 'replace_children': + case 'replace_fragment': + return areJsonEqual(operation.children, operation.newChildren) + default: + return false + } +} const isSlateText = ( node: unknown @@ -32,9 +47,20 @@ const isSlateText = ( 'text' in node && typeof (node as { text?: unknown }).text === 'string' +const isSlateElement = (node: unknown): node is SlateElementLike => + typeof node === 'object' && + node !== null && + 'children' in node && + Array.isArray((node as { children?: unknown }).children) + const getTextAttributes = ({ text: _text, ...attributes }: { text: string }) => attributes as Record +const getElementAttributes = ({ + children: _children, + ...attributes +}: SlateElementLike) => attributes + const createYjsText = (text: string, attributes: Record) => { const yjsText = new Y.XmlText() @@ -216,37 +242,84 @@ const replaceYjsText = ( } } -const replaceTextChildren = ( +const canReplaceCompatibleYjsChildren = ( children: Array, - oldChildren: ReplaceFragmentOperation['children'], - newChildren: ReplaceFragmentOperation['newChildren'] -) => { + oldChildren: readonly Descendant[], + newChildren: readonly Descendant[] +): boolean => { if ( children.length !== oldChildren.length || - children.length !== newChildren.length || - children.some((child) => !(child instanceof Y.XmlText)) || - oldChildren.some((child) => !isSlateText(child)) || - newChildren.some((child) => !isSlateText(child)) + children.length !== newChildren.length ) { return false } - children.forEach((child, index) => { + return children.every((child, index) => { const oldChild = oldChildren[index] const newChild = newChildren[index] + if (child instanceof Y.XmlText) { + return isSlateText(oldChild) && isSlateText(newChild) + } + if ( - !(child instanceof Y.XmlText) || - !isSlateText(oldChild) || - !isSlateText(newChild) + child instanceof Y.XmlElement && + isSlateElement(oldChild) && + isSlateElement(newChild) ) { - return + return canReplaceCompatibleYjsChildren( + getYjsChildren(child), + oldChild.children, + newChild.children + ) } - const attributes = getTextAttributes(newChild) + return false + }) +} + +const replaceCompatibleYjsChildren = ( + children: Array, + oldChildren: readonly Descendant[], + newChildren: readonly Descendant[] +): boolean => { + if (!canReplaceCompatibleYjsChildren(children, oldChildren, newChildren)) { + return false + } + + children.forEach((child, index) => { + const oldChild = oldChildren[index]! + const newChild = newChildren[index]! + + if (child instanceof Y.XmlText) { + if (!isSlateText(oldChild) || !isSlateText(newChild)) { + return + } + + const attributes = getTextAttributes(newChild) + + setYjsNodeAttributes(child, getTextAttributes(oldChild), attributes) + replaceYjsText(child, oldChild.text, newChild.text, attributes) - setYjsNodeAttributes(child, getTextAttributes(oldChild), attributes) - replaceYjsText(child, oldChild.text, newChild.text, attributes) + return + } + + if ( + child instanceof Y.XmlElement && + isSlateElement(oldChild) && + isSlateElement(newChild) + ) { + setYjsNodeAttributes( + child, + getElementAttributes(oldChild), + getElementAttributes(newChild) + ) + replaceCompatibleYjsChildren( + getYjsChildren(child), + oldChild.children, + newChild.children + ) + } }) return true @@ -260,6 +333,10 @@ export const applySlateOperationToYjs = ( root: Y.XmlElement, operation: Operation ): YjsTraceEntry | null => { + if (isNoopSlateOperationForYjs(operation)) { + return null + } + switch (operation.type) { case 'insert_text': { const text = getYjsNode(root, operation.path) @@ -286,7 +363,7 @@ export const applySlateOperationToYjs = ( case 'insert_node': { const { index, parent } = getYjsParent(root, operation.path) - parent.insert(index, [createYjsNode(operation.node)]) + insertYjsChild(root, parent, index, createYjsNode(operation.node)) return { mode: 'operation', operationType: operation.type } } @@ -322,12 +399,15 @@ export const applySlateOperationToYjs = ( target.delete(operation.position, rightText.length) } - parent.insert(index + 1, [ + insertYjsChild( + root, + parent, + index + 1, createYjsText( rightText, operation.properties as Record - ), - ]) + ) + ) return { mode: 'operation', operationType: operation.type } } @@ -342,13 +422,16 @@ export const applySlateOperationToYjs = ( target.delete(operation.position, deleteCount) } - parent.insert(index + 1, [ + insertYjsChild( + root, + parent, + index + 1, createSplitElement( target, operation.properties as Record, rightChildren - ), - ]) + ) + ) return { mode: 'operation', operationType: operation.type } } @@ -406,7 +489,11 @@ export const applySlateOperationToYjs = ( const children = getYjsChildren(target) if ( - replaceTextChildren(children, operation.children, operation.newChildren) + replaceCompatibleYjsChildren( + children, + operation.children, + operation.newChildren + ) ) { return { mode: 'operation', operationType: operation.type } } @@ -446,6 +533,21 @@ export const applySlateOperationToYjs = ( throw new Error('replace_children target is not a Y.XmlElement.') } + const existingChildren = getYjsVisibleChildren(root, target).slice( + operation.index, + operation.index + operation.children.length + ) + + if ( + replaceCompatibleYjsChildren( + existingChildren, + operation.children, + operation.newChildren + ) + ) { + return { mode: 'operation', operationType: operation.type } + } + const removalModes = operation.children.map((child) => removeYjsChild(root, target, operation.index, child) ) diff --git a/packages/slate-yjs/src/core/undo-manager-adapter.ts b/packages/slate-yjs/src/core/undo-manager-adapter.ts index 3b05085f9c..a527a17fc5 100644 --- a/packages/slate-yjs/src/core/undo-manager-adapter.ts +++ b/packages/slate-yjs/src/core/undo-manager-adapter.ts @@ -58,6 +58,9 @@ export const createYjsUndoManagerAdapter = (undoManager: Y.UndoManager) => { peekUndo() { return undo().at(-1) ?? null }, + redoDepth() { + return redo().length + }, storeUndoMeta(key: unknown, value: unknown) { undo().at(-1)?.meta.set(key, value) }, diff --git a/packages/slate-yjs/test/replace-fragment-contract.spec.ts b/packages/slate-yjs/test/replace-fragment-contract.spec.ts index 448cc4da5a..e00f683487 100644 --- a/packages/slate-yjs/test/replace-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/replace-fragment-contract.spec.ts @@ -195,6 +195,30 @@ const appendRemoteText = (peer: Peer) => { }) } +const insertLocalBang = (peer: Peer) => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) +} + +const replayNoopRootReplaceFragment = (peer: Peer) => { + const operation: Operation = { + children: initialValue(), + newChildren: initialValue(), + newSelection: { + anchor: { path: [0, 0], offset: 'alpha'.length }, + focus: { path: [0, 0], offset: 'alpha'.length }, + }, + path: [], + selection: null, + type: 'replace_fragment', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + describe('@slate/yjs replace_fragment collaboration contract', () => { it('applies local offline single-text replace_fragment without replacing the Yjs text node', () => { const peer = createPeer('b') @@ -284,6 +308,26 @@ describe('@slate/yjs replace_fragment collaboration contract', () => { assertNoRootSnapshot(b) }) + it('ignores no-op replace_fragment so redo history stays usable', () => { + const peer = createPeer('b') + + insertLocalBang(peer) + assert.deepEqual(paragraphTexts(peer), ['alpha!']) + + yjsUpdate(peer, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(peer), ['alpha']) + + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + replayNoopRootReplaceFragment(peer) + + assert.deepEqual(paragraphTexts(peer), ['alpha']) + assert.deepEqual(yjsState(peer).trace(), []) + + yjsUpdate(peer, (yjs) => yjs.redo()) + assert.deepEqual(paragraphTexts(peer), ['alpha!']) + assertNoRootSnapshot(peer) + }) + it('uses a traceable fallback for broad replace_fragment replacement', () => { const peer = createPeer('b') diff --git a/packages/slate-yjs/test/simple-operations-contract.spec.ts b/packages/slate-yjs/test/simple-operations-contract.spec.ts index 81465ca0f7..ad77796c25 100644 --- a/packages/slate-yjs/test/simple-operations-contract.spec.ts +++ b/packages/slate-yjs/test/simple-operations-contract.spec.ts @@ -85,6 +85,22 @@ const replaceMiddleBlock = (peer: ReturnType) => { }) } +const replaceFirstBlock = (peer: ReturnType) => { + const operation: Operation = { + children: [paragraph('alpha')], + index: 0, + newChildren: [paragraph('bravo')], + newSelection: null, + path: [], + selection: null, + type: 'replace_children', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + describe('@slate/yjs simple operation collaboration contract', () => { it('applies local offline insert_text in place without a root snapshot fallback', () => { const peer = createPeer('b') @@ -250,4 +266,27 @@ describe('@slate/yjs simple operation collaboration contract', () => { assertPeerTexts(peers, ['alpha!', 'bravo', 'gamma']) assertNoRootSnapshot(b) }) + + it('preserves remote text when an offline replace_children is undone before reconnect', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a, b] = peers + + runYjsUpdate(b, (yjs) => yjs.disconnect()) + replaceFirstBlock(b) + assert.deepEqual(getParagraphTexts(b), ['bravo', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.undo()) + assert.deepEqual(getParagraphTexts(b), ['alpha', 'beta', 'gamma']) + + appendRemoteAlpha(a) + syncConnectedPeers(peers) + assert.deepEqual(getParagraphTexts(a), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getParagraphTexts(b), ['alpha', 'beta', 'gamma']) + + runYjsUpdate(b, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) + assertNoRootSnapshot(b) + }) }) diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts index f1891a24a7..f674ea81a5 100644 --- a/packages/slate-yjs/test/split-node-contract.spec.ts +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -18,11 +18,15 @@ const paragraph = (text: string): Descendant => ({ const initialValue = () => [paragraph('alphabeta')] -const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => { +const createPeer = ( + clientId: string, + seedUpdate?: Uint8Array, + children = initialValue() +): Peer => { const editor = createEditor() Editor.replace(editor, { - children: initialValue(), + children, selection: null, marks: null, }) @@ -143,6 +147,24 @@ const appendRemoteText = (peer: Peer) => { }) } +const insertTextSplitAndInsertRightText = (peer: Peer) => { + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }) + }) + peer.editor.update((tx) => { + tx.text.insert('a') + }) + peer.editor.update((tx) => { + tx.break.insert() + }) + peer.editor.update((tx) => { + tx.text.insert('b') + }) +} + describe('@slate/yjs split_node collaboration contract', () => { it('applies local offline public split without a root snapshot fallback', () => { const peer = createPeer('b') @@ -215,6 +237,87 @@ describe('@slate/yjs split_node collaboration contract', () => { assertNoRootSnapshot(b) }) + it('redoes text inserted into a split-created paragraph after undoing to an empty document', () => { + const peer = createPeer('b', undefined, [paragraph('')]) + + insertTextSplitAndInsertRightText(peer) + assert.deepEqual(paragraphTexts(peer), ['a', 'b']) + + yjsUpdate(peer, (yjs) => yjs.undo()) + yjsUpdate(peer, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(peer), ['a']) + + yjsUpdate(peer, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(peer), ['']) + + yjsUpdate(peer, (yjs) => yjs.redo()) + assert.deepEqual(paragraphTexts(peer), ['a']) + + yjsUpdate(peer, (yjs) => yjs.redo()) + yjsUpdate(peer, (yjs) => yjs.redo()) + assert.deepEqual(paragraphTexts(peer), ['a', 'b']) + assertNoRootSnapshot(peer) + }) + + it('undoes a split after a prior merge without custom split-history replay', () => { + const peer = createPeer('b', undefined, [ + paragraph('Hello world!'), + paragraph('block 2'), + ]) + + peer.editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) + assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + path: [0, 0], + position: 'Hello wor'.length, + properties: {}, + type: 'split_node', + }, + { + path: [0], + position: 1, + properties: { type: 'paragraph' }, + type: 'split_node', + }, + ]) + }) + assert.deepEqual(paragraphTexts(peer), ['Hello wor', 'ld!block 2']) + + yjsUpdate(peer, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) + assertNoRootSnapshot(peer) + }) + + it('undoes a break split after a prior merge without leaving the right split node visible', () => { + const peer = createPeer('b', undefined, [ + paragraph('Hello world!'), + paragraph('block 2'), + ]) + + peer.editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) + assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 'Hello wor'.length }, + focus: { path: [0, 0], offset: 'Hello wor'.length }, + }) + tx.break.insert() + }) + assert.deepEqual(paragraphTexts(peer), ['Hello wor', 'ld!block 2']) + + yjsUpdate(peer, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) + assertNoRootSnapshot(peer) + }) + it('undoes an offline public split after a concurrent remote append', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts b/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts index 6d55e2142e..5ad4c98d5b 100644 --- a/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts +++ b/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts @@ -8,15 +8,11 @@ import { SUPPORTED_YJS_UNDO_MANAGER_VERSION, } from '../src/core/undo-manager-adapter' -describe('@slate/yjs Yjs UndoManager stack adapter contract', () => { - it('pins the Yjs stack contract to the audited version', () => { - assert.equal(SUPPORTED_YJS_UNDO_MANAGER_VERSION, '13.6.30') - }) - - it('stores metadata and moves audited stack items through the adapter', () => { +describe('@slate/yjs UndoManager adapter contract', () => { + it('isolates the private Yjs stack access used by split history replay', () => { const doc = new Y.Doc() - const origin = {} const root = doc.get('slate', Y.XmlElement) + const origin = {} const undoManager = new Y.UndoManager(root, { trackedOrigins: new Set([origin]), }) @@ -34,17 +30,17 @@ describe('@slate/yjs Yjs UndoManager stack adapter contract', () => { assert.equal(undoItem.meta.get('contract'), 42) adapter.moveUndoToRedo(undoItem) - assert.equal(adapter.peekUndo(), null) assert.equal(adapter.peekRedo(), undoItem) + assert.equal(adapter.redoDepth(), 1) adapter.moveRedoToUndo(undoItem) assert.equal(adapter.peekUndo(), undoItem) - assert.equal(adapter.peekRedo(), null) undoManager.destroy() + doc.destroy() }) - it('keeps private stack property access isolated to the adapter', () => { + it('pins Yjs private stack usage to one adapter file and a fixed version', () => { const controllerSource = readFileSync( new URL('../src/core/controller.ts', import.meta.url), 'utf8' @@ -54,6 +50,7 @@ describe('@slate/yjs Yjs UndoManager stack adapter contract', () => { 'utf8' ) + assert.equal(SUPPORTED_YJS_UNDO_MANAGER_VERSION, '13.6.30') assert.equal(controllerSource.includes('undoStack'), false) assert.equal(controllerSource.includes('redoStack'), false) assert.equal(adapterSource.includes('undoStack'), true) diff --git a/packages/slate-yjs/test/wrap-nodes-contract.spec.ts b/packages/slate-yjs/test/wrap-nodes-contract.spec.ts index 38935ad0f8..b0a6adbc20 100644 --- a/packages/slate-yjs/test/wrap-nodes-contract.spec.ts +++ b/packages/slate-yjs/test/wrap-nodes-contract.spec.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { type Descendant, defineEditorExtension } from 'slate' import { Editor } from 'slate/internal' - +import { readSlateValueFromYjs } from '../src/core/document' import { assertNoRootSnapshot, assertPeerTexts, @@ -136,6 +136,39 @@ describe('@slate/yjs wrapNodes collaboration contract', () => { assertNoRootSnapshot(b) }) + it('splits text inside a virtual wrapped block without a root snapshot fallback', () => { + const peer = createPeer('b') + + wrapFirstBlock(peer) + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [0, 0, 0], offset: 2 }, + }) + tx.break.insert() + }) + + assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + { + children: [paragraph('al'), paragraph('pha')], + type: 'quote', + }, + ]) + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + { + children: [paragraph('al'), paragraph('pha')], + type: 'quote', + }, + ]) + assert.deepEqual(getYjsState(peer).trace(), [ + { mode: 'operation', operationType: 'split_node' }, + { mode: 'operation', operationType: 'split_node' }, + ]) + assertNoRootSnapshot(peer) + }) + it('drops a preserved selection that no longer points to text after remote wrap import', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts index 76adf725b8..e26c79298a 100644 --- a/playwright/integration/examples/yjs-collaboration.test.ts +++ b/playwright/integration/examples/yjs-collaboration.test.ts @@ -32,6 +32,18 @@ const getPeerParagraphTexts = (page: Page, peer: PeerId) => }) ) +const expectAllPeerParagraphTexts = async (page: Page, expected: string[]) => { + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect.poll(() => getPeerParagraphTexts(page, peer)).toEqual(expected) + } +} + +const expectNoPeerBlockQuotes = async (page: Page) => { + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect(peerTextbox(page, peer).locator('blockquote')).toHaveCount(0) + } +} + const getPeerLayoutProof = (page: Page, peer: PeerId) => peerTextbox(page, peer).evaluate((textbox) => { const editorRect = textbox.getBoundingClientRect() @@ -433,6 +445,65 @@ test.describe('yjs collaboration example', () => { } }) + test('keeps set and unset role controls symmetric', async ({ page }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await expect(byTestId(page, 'yjs-peer-a-set-node')).toHaveText('Set Role') + await expect(byTestId(page, 'yjs-peer-a-unset-node')).toHaveText( + 'Unset Role' + ) + + await byTestId(page, 'yjs-peer-a-disconnect').click() + await byTestId(page, 'yjs-peer-a-set-node').click() + + const setCommit = (await editor.get.lastCommit()) as { + operations?: Array<{ + newProperties?: unknown + path?: number[] + properties?: unknown + type?: string + }> + } | null + const setOperation = setCommit?.operations?.find( + (operation) => operation.type === 'set_node' + ) + + expect(setOperation).toEqual( + expect.objectContaining({ + newProperties: { role: 'title' }, + path: [0], + properties: {}, + type: 'set_node', + }) + ) + + await byTestId(page, 'yjs-peer-a-unset-node').click() + + const unsetCommit = (await editor.get.lastCommit()) as { + operations?: Array<{ + newProperties?: unknown + path?: number[] + properties?: unknown + type?: string + }> + } | null + const unsetOperation = unsetCommit?.operations?.find( + (operation) => operation.type === 'set_node' + ) + + expect(unsetOperation).toEqual( + expect.objectContaining({ + newProperties: {}, + path: [0], + properties: { role: 'title' }, + type: 'set_node', + }) + ) + }) + test('syncs marks applied from one editor to all connected editors', async ({ page, }) => { @@ -1045,6 +1116,146 @@ test.describe('yjs collaboration example', () => { } }) + test('undoes a merge followed by a split without a split-history error', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['Hello world!', 'block 2']) + await expectAllPeerParagraphTexts(page, ['Hello world!', 'block 2']) + + await byTestId(page, 'yjs-peer-a-merge-node').click() + await byTestId(page, 'yjs-peer-a-split-node').click() + await byTestId(page, 'yjs-peer-a-undo').click() + + await expectAllPeerParagraphTexts(page, ['Hello world!block 2']) + expect(pageErrors).toEqual([]) + }) + + test('redoes multi-paragraph keyboard input after undoing to an empty document', async ({ + page, + }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['']) + await expectAllPeerParagraphTexts(page, ['']) + + await placePeerCaret(page, 'a', 0, 0) + await page.keyboard.type('a') + await page.keyboard.press('Enter') + await page.keyboard.type('b') + + await expectAllPeerParagraphTexts(page, ['a', 'b']) + + for (let index = 0; index < 2; index++) { + await byTestId(page, 'yjs-peer-a-undo').click() + } + + await expectAllPeerParagraphTexts(page, ['']) + await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() + + for (let index = 0; index < 2; index++) { + await byTestId(page, 'yjs-peer-a-redo').click() + } + + await expectAllPeerParagraphTexts(page, ['a', 'b']) + await expect(byTestId(page, 'yjs-peer-a-redo')).toBeDisabled() + }) + + test('keyboard redoes multi-paragraph input after undoing to an empty document', async ({ + page, + }) => { + const editor = await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await replacePeerText(page, 'a', ['']) + await expectAllPeerParagraphTexts(page, ['']) + + await placePeerCaret(page, 'a', 0, 0) + await page.keyboard.type('a') + await page.keyboard.press('Enter') + await page.keyboard.type('b') + + await expectAllPeerParagraphTexts(page, ['a', 'b']) + + await peerTextbox(page, 'a').focus() + const { redo, undo } = await getHistoryShortcuts(page) + + await page.keyboard.press(undo) + await page.keyboard.press(undo) + + await expectAllPeerParagraphTexts(page, ['']) + await expect + .poll(() => editor.selection.get()) + .toEqual({ + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }) + await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( + '101:0.0:0-0.0:0' + ) + await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() + + await page.keyboard.press(redo) + await page.keyboard.press(redo) + + await expectAllPeerParagraphTexts(page, ['a', 'b']) + await expect + .poll(() => editor.selection.get()) + .toEqual({ + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 1 }, + }) + await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( + '101:1.0:1-1.0:1' + ) + await expect(byTestId(page, 'yjs-peer-a-redo')).toBeDisabled() + }) + + test('keeps no-op structural commands out of history', async ({ page }) => { + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + const commands = [ + 'remove-node', + 'merge-node', + 'unwrap', + 'lift', + 'unset-node', + ] as const + + for (const command of commands) { + await test.step(command, async () => { + await replacePeerText(page, 'a', ['Hello world!']) + await expectAllPeerParagraphTexts(page, ['Hello world!']) + await expectNoPeerBlockQuotes(page) + await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() + + await byTestId(page, `yjs-peer-a-${command}`).click() + + await expectAllPeerParagraphTexts(page, ['Hello world!']) + await expectNoPeerBlockQuotes(page) + await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() + }) + } + }) + test('preserves concurrent text when an offline wrap button reconnects', async ({ page, }) => { @@ -1109,6 +1320,184 @@ test.describe('yjs collaboration example', () => { } }) + test('replaces a disconnected wrapped first block without stale text paths', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-c-wrap-node').click() + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-replace').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Lin canonical snapshot.']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + await expectAllPeerParagraphTexts(page, ['Lin canonical snapshot.']) + expect(pageErrors).toEqual([]) + }) + + test('replaces a disconnected fragmented first block without stale text offsets', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-insert-fragment').click() + await expectAllPeerParagraphTexts(page, ['Hello world!Lin fragment']) + + await byTestId(page, 'yjs-peer-b-disconnect').click() + await byTestId(page, 'yjs-peer-b-replace').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Lin canonical snapshot.']) + + await byTestId(page, 'yjs-peer-b-connect').click() + + await expectAllPeerParagraphTexts(page, ['Lin canonical snapshot.']) + expect(pageErrors).toEqual([]) + }) + + test('splits a wrapped first block without page errors', async ({ page }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-wrap-node').click() + await byTestId(page, 'yjs-peer-d-split-node').click() + + await expectAllPeerParagraphTexts(page, ['Hello ', 'world!']) + expect(pageErrors).toEqual([]) + }) + + test('keeps the initiating peer DOM synchronized after split then merge buttons', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-split-node').click() + await expectAllPeerParagraphTexts(page, ['Hello ', 'world!']) + + await byTestId(page, 'yjs-peer-d-merge-node').click() + + await expectAllPeerParagraphTexts(page, ['Hello world!']) + expect(pageErrors).toEqual([]) + }) + + test('keeps connected fragment button edits converged without page errors', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-insert-fragment').click() + await expectAllPeerParagraphTexts(page, ['Hello world!Lin fragment']) + + await byTestId(page, 'yjs-peer-d-insert-fragment').click() + + await expectAllPeerParagraphTexts(page, [ + 'Hello world!Lin fragmentEve fragment', + ]) + expect(pageErrors).toEqual([]) + }) + + test('runs text commands inside a wrapped first block without stale paths', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-c-wrap-node').click() + await byTestId(page, 'yjs-peer-b-insert-text').click() + await expectAllPeerParagraphTexts(page, ['Hello world!!']) + + await byTestId(page, 'yjs-peer-d-delete-backward').click() + await expectAllPeerParagraphTexts(page, ['Hello world!']) + + await byTestId(page, 'yjs-peer-a-delete-fragment').click() + await expectAllPeerParagraphTexts(page, [' world!']) + expect(pageErrors).toEqual([]) + }) + + test('uses fresh text paths after append, backspace, and fragment buttons', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-b-append').click() + await expectAllPeerParagraphTexts(page, ['Hello world! Lin']) + + await byTestId(page, 'yjs-peer-d-delete-backward').click() + await expectAllPeerParagraphTexts(page, ['Hello world! Li']) + + await byTestId(page, 'yjs-peer-b-insert-fragment').click() + await expectAllPeerParagraphTexts(page, ['Hello world! LiLin fragment']) + + await byTestId(page, 'yjs-peer-d-delete-backward').click() + + await expectAllPeerParagraphTexts(page, ['Hello world! LiLin fragmen']) + expect(pageErrors).toEqual([]) + }) + test('undoes offline Backspace merge after a concurrent text edit reconnects', async ({ page, }) => { diff --git a/scripts/benchmarks/core/current/yjs-collaboration.mjs b/scripts/benchmarks/core/current/yjs-collaboration.mjs new file mode 100644 index 0000000000..c631daa899 --- /dev/null +++ b/scripts/benchmarks/core/current/yjs-collaboration.mjs @@ -0,0 +1,389 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' + +import { createEditor } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { createYjsExtension } from '../../../../packages/slate-yjs/src/index.ts' +import { summarize, writeBenchmarkArtifact } from '../../shared/stats.mjs' + +const iterations = Number.parseInt( + process.env.SLATE_YJS_COLLAB_ITERATIONS ?? '5', + 10 +) +const peerCount = Number.parseInt(process.env.SLATE_YJS_COLLAB_PEERS ?? '4', 10) +const syncBlocks = Number.parseInt( + process.env.SLATE_YJS_COLLAB_SYNC_BLOCKS ?? '100', + 10 +) +const syncOps = Number.parseInt( + process.env.SLATE_YJS_COLLAB_SYNC_OPS ?? '40', + 10 +) +const awarenessUpdates = Number.parseInt( + process.env.SLATE_YJS_COLLAB_AWARENESS_UPDATES ?? '100', + 10 +) +const reconnectOps = Number.parseInt( + process.env.SLATE_YJS_COLLAB_RECONNECT_OPS ?? '40', + 10 +) +const largeBlocks = Number.parseInt( + process.env.SLATE_YJS_COLLAB_LARGE_BLOCKS ?? '1000', + 10 +) +const largeOps = Number.parseInt( + process.env.SLATE_YJS_COLLAB_LARGE_OPS ?? '120', + 10 +) + +class FakeAwareness { + constructor(clientID) { + this.clientID = clientID + this.doc = { clientID } + this.listeners = new Set() + this.localState = null + this.states = new Map() + } + + getLocalState() { + return this.localState + } + + getStates() { + return this.states + } + + off(event, handler) { + if (event === 'change') { + this.listeners.delete(handler) + } + } + + on(event, handler) { + if (event === 'change') { + this.listeners.add(handler) + } + } + + setLocalStateField(field, value) { + this.localState = { + ...(this.localState ?? {}), + [field]: value, + } + this.states.set(this.clientID, this.localState) + this.emit({ added: [], removed: [], updated: [this.clientID] }) + } + + setRemoteState(clientId, state) { + const added = this.states.has(clientId) ? [] : [clientId] + const updated = this.states.has(clientId) ? [clientId] : [] + + this.states.set(clientId, state) + this.emit({ added, removed: [], updated }) + } + + emit(event) { + for (const listener of this.listeners) { + listener(event) + } + } +} + +const paragraph = (text) => ({ + type: 'paragraph', + children: [{ text }], +}) + +const createDocument = (blocks, prefix = 'block') => + Array.from({ length: blocks }, (_, index) => + paragraph(`${prefix}-${String(index).padStart(5, '0')}`) + ) + +const createPeer = ({ + awareness, + children, + clientId, + numericClientId, + seed, +}) => { + const editor = createEditor() + + Editor.replace(editor, { + children: structuredClone(children), + marks: null, + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + }) + + const doc = new Y.Doc() + + if (numericClientId !== undefined) { + doc.clientID = numericClientId + } + + if (seed) { + Y.applyUpdate(doc, seed) + } + + editor.extend( + createYjsExtension({ awareness, clientId, doc, rootName: 'slate' }) + ) + + return { awareness, doc, editor, id: clientId } +} + +const createSeededPeers = ({ + blocks, + prefix = 'block', + withAwareness = false, +}) => { + const children = createDocument(blocks, prefix) + const ids = Array.from({ length: peerCount }, (_, index) => `peer-${index}`) + const firstAwareness = withAwareness ? new FakeAwareness(101) : undefined + const first = createPeer({ + awareness: firstAwareness, + children, + clientId: ids[0], + numericClientId: 101, + }) + const seed = Y.encodeStateAsUpdate(first.doc) + + return [ + first, + ...ids.slice(1).map((clientId, index) => { + const numericClientId = 102 + index + + return createPeer({ + awareness: withAwareness + ? new FakeAwareness(numericClientId) + : undefined, + children, + clientId, + numericClientId, + seed, + }) + }), + ] +} + +const getYjsState = (peer) => peer.editor.read((state) => state.yjs) + +const runYjsUpdate = (peer, fn) => { + peer.editor.update((tx) => { + fn(tx.yjs) + }) +} + +const getParagraphTexts = (peer) => + Editor.getSnapshot(peer.editor).children.map((_, index) => + Editor.string(peer.editor, [index]) + ) + +const syncConnectedPeers = (peers) => { + for (const source of peers) { + if (!getYjsState(source).connected()) { + continue + } + + const update = Y.encodeStateAsUpdate(source.doc) + + for (const target of peers) { + if (source === target || !getYjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } +} + +const assertPeerTexts = (peers) => { + const expected = getParagraphTexts(peers[0]) + + for (const peer of peers) { + assert.deepEqual(getParagraphTexts(peer), expected) + } +} + +const assertNoRootSnapshot = (peer) => { + assert.equal( + getYjsState(peer) + .trace() + .some((entry) => entry.mode === 'root-snapshot'), + false + ) +} + +const measure = (run) => { + const samples = [] + + for (let iteration = 0; iteration < iterations + 1; iteration += 1) { + const start = performance.now() + run() + const duration = performance.now() - start + + if (iteration > 0) { + samples.push(duration) + } + } + + return summarize(samples) +} + +const insertDistributedText = (peer, ops, blocks, textPrefix) => { + peer.editor.update((tx) => { + for (let index = 0; index < ops; index += 1) { + const blockIndex = index % blocks + tx.text.insert(`${textPrefix}${index % 10}`, { + at: { path: [blockIndex, 0], offset: 0 }, + }) + } + }) +} + +const measureMultiEditorSync = () => + measure(() => { + const peers = createSeededPeers({ blocks: syncBlocks, prefix: 'sync' }) + + insertDistributedText(peers[0], syncOps, syncBlocks, 's') + syncConnectedPeers(peers) + + assertPeerTexts(peers) + assertNoRootSnapshot(peers[0]) + }) + +const broadcastAwareness = (source, targets) => { + const state = source.awareness.getLocalState() + + assert(state) + + for (const target of targets) { + target.awareness.setRemoteState(source.doc.clientID, state) + } +} + +const selection = (blockIndex, offset = 1) => ({ + anchor: { path: [blockIndex, 0], offset }, + focus: { path: [blockIndex, 0], offset }, +}) + +const measureAwarenessUpdates = () => + measure(() => { + const blocks = Math.max(1, Math.min(syncBlocks, awarenessUpdates)) + const peers = createSeededPeers({ + blocks, + prefix: 'awareness', + withAwareness: true, + }) + + for (let index = 0; index < awarenessUpdates; index += 1) { + const source = peers[index % peers.length] + const targets = peers.filter((peer) => peer !== source) + + runYjsUpdate(source, (yjs) => { + yjs.sendSelection(selection(index % blocks), { + name: source.id, + update: index, + }) + }) + broadcastAwareness(source, targets) + } + + for (const peer of peers) { + assert.equal(getYjsState(peer).remoteCursors().length, peerCount - 1) + } + }) + +const measureReconnect = () => + measure(() => { + const peers = createSeededPeers({ blocks: syncBlocks, prefix: 'reconnect' }) + const [online, offline] = peers + + runYjsUpdate(offline, (yjs) => yjs.disconnect()) + insertDistributedText(offline, reconnectOps, syncBlocks, 'o') + insertDistributedText(online, reconnectOps, syncBlocks, 'r') + syncConnectedPeers(peers) + + runYjsUpdate(offline, (yjs) => yjs.connect()) + syncConnectedPeers(peers) + + assertPeerTexts(peers) + assertNoRootSnapshot(offline) + }) + +const measureLargeDocSync = () => + measure(() => { + const peers = createSeededPeers({ blocks: largeBlocks, prefix: 'large' }) + + insertDistributedText(peers[0], largeOps, largeBlocks, 'l') + syncConnectedPeers(peers) + + assertPeerTexts(peers) + assertNoRootSnapshot(peers[0]) + }) + +const lanes = { + multiEditorSyncMs: measureMultiEditorSync(), + awarenessUpdatesMs: measureAwarenessUpdates(), + reconnectMs: measureReconnect(), + largeDocSyncMs: measureLargeDocSync(), +} + +const metrics = { + yjs_multi_editor_sync_p95_ms: lanes.multiEditorSyncMs.p95, + yjs_awareness_updates_p95_ms: lanes.awarenessUpdatesMs.p95, + yjs_reconnect_p95_ms: lanes.reconnectMs.p95, + yjs_large_doc_sync_p95_ms: lanes.largeDocSyncMs.p95, + yjs_collaboration_worst_p95_ms: Math.max( + lanes.multiEditorSyncMs.p95, + lanes.awarenessUpdatesMs.p95, + lanes.reconnectMs.p95, + lanes.largeDocSyncMs.p95 + ), + yjs_correctness_failures: 0, +} + +const result = { + benchmark: 'slate-yjs-collaboration', + artifactVersion: 1, + config: { + awarenessUpdates, + iterations, + largeBlocks, + largeOps, + peerCount, + reconnectOps, + syncBlocks, + syncOps, + }, + invariants: { + awarenessCursorsConverge: true, + connectedPeersOnlyReceiveUpdates: true, + largeDocumentConverges: true, + multiEditorConverges: true, + noRootSnapshotFallback: true, + reconnectConverges: true, + }, + lanes, + metrics, + thresholdPolicy: { + mode: 'calibration-only', + releaseGate: false, + repeatRunsRequiredBeforeEnforcement: 3, + }, +} + +await writeBenchmarkArtifact( + 'tmp/slate-yjs-collaboration-benchmark.json', + result +) + +for (const [name, value] of Object.entries(metrics)) { + console.log(`METRIC ${name}=${value}`) +} + +console.log(JSON.stringify(result, null, 2)) diff --git a/site/examples/ts/yjs-collaboration.tsx b/site/examples/ts/yjs-collaboration.tsx index b9d02ac1f8..75f57aca61 100644 --- a/site/examples/ts/yjs-collaboration.tsx +++ b/site/examples/ts/yjs-collaboration.tsx @@ -134,6 +134,21 @@ const recordPeerUndoGroup = ( syncPeerHistoryDepths(peer) } +const areJsonEqual = (left: unknown, right: unknown) => + JSON.stringify(left) === JSON.stringify(right) + +const changesDocument = (operation: Operation) => { + switch (operation.type) { + case 'set_selection': + return false + case 'replace_children': + case 'replace_fragment': + return !areJsonEqual(operation.children, operation.newChildren) + default: + return true + } +} + const INITIAL_VALUE: CustomValue = [ { type: 'paragraph', @@ -262,10 +277,7 @@ const createExampleNetwork = (): ExampleNetwork => { if (network.syncing) { return } - if ( - operations.length === 0 || - operations.some((operation) => operation.type !== 'set_selection') - ) { + if (operations.some(changesDocument)) { recordPeerUndoGroup( peer, peer.pendingLocalChangeKind ?? 'keyboard', @@ -370,28 +382,76 @@ const hasDescendantChildren = ( ): node is Descendant & { children: readonly Descendant[] } => 'children' in node && Array.isArray(node.children) -const findLastTextEntry = ( - nodes: readonly Descendant[], - basePath: number[] = [] +const findFirstTextEntryInNode = ( + node: Descendant, + path: number[] ): TextEntry | null => { - for (let index = nodes.length - 1; index >= 0; index--) { - const node = nodes[index] + if (isCustomText(node)) { + return { path, text: node.text } + } - if (!node) { + if (!hasDescendantChildren(node)) { + return null + } + + for (let index = 0; index < node.children.length; index++) { + const child = node.children[index] + + if (!child) { continue } - const path = [...basePath, index] + const entry = findFirstTextEntryInNode(child, [...path, index]) - if (isCustomText(node)) { - return { path, text: node.text } + if (entry) { + return entry } + } + + return null +} + +const findLastTextEntryInNode = ( + node: Descendant, + path: number[] +): TextEntry | null => { + if (isCustomText(node)) { + return { path, text: node.text } + } - if (!hasDescendantChildren(node)) { + if (!hasDescendantChildren(node)) { + return null + } + + for (let index = node.children.length - 1; index >= 0; index--) { + const child = node.children[index] + + if (!child) { continue } - const entry = findLastTextEntry(node.children, path) + const entry = findLastTextEntryInNode(child, [...path, index]) + + if (entry) { + return entry + } + } + + return null +} + +const findLastTextEntry = ( + nodes: readonly Descendant[], + basePath: number[] = [] +): TextEntry | null => { + for (let index = nodes.length - 1; index >= 0; index--) { + const node = nodes[index] + + if (!node) { + continue + } + + const entry = findLastTextEntryInNode(node, [...basePath, index]) if (entry) { return entry @@ -435,6 +495,21 @@ const getTextEntryAtPath = ( return current && isCustomText(current) ? { path, text: current.text } : null } +const getFirstBlockTextEntry = ( + editor: CustomEditor, + position: 'first' | 'last' +) => { + const [block] = getEditorValue(editor) + + if (!block) { + return null + } + + return position === 'first' + ? findFirstTextEntryInNode(block, [0]) + : findLastTextEntryInNode(block, [0]) +} + const pointAtTextEnd = (entry: TextEntry) => ({ path: entry.path, offset: entry.text.length, @@ -447,6 +522,10 @@ const isCollapsedSelection = (selection: Range) => selection.anchor.path.join('.') === selection.focus.path.join('.') && selection.anchor.offset === selection.focus.offset +const isSamePath = (left: readonly number[], right: readonly number[]) => + left.length === right.length && + left.every((part, index) => part === right[index]) + const isSelectionAtTextEnd = (value: CustomValue, selection: Range) => { if (!isCollapsedSelection(selection)) { return false @@ -457,13 +536,36 @@ const isSelectionAtTextEnd = (value: CustomValue, selection: Range) => { return entry ? selection.anchor.offset === entry.text.length : false } +const isSelectionAtDocumentEnd = (value: CustomValue, selection: Range) => { + if (!isCollapsedSelection(selection)) { + return false + } + + const entry = findLastTextEntry(value) + + return ( + !!entry && + isSamePath(selection.anchor.path, entry.path) && + selection.anchor.offset === entry.text.length + ) +} + const normalizeHistorySelection = ( value: CustomValue, selection: Range | null, - options: { preferEndOfPreviousEndSelection?: Range | null } = {} + options: { + preferDocumentEnd?: boolean | null + preferEndOfPreviousEndSelection?: Range | null + } = {} ): Range | null => { const fallbackEntry = findLastTextEntry(value) + if (options.preferDocumentEnd && fallbackEntry) { + const point = pointAtTextEnd(fallbackEntry) + + return { anchor: point, focus: point } + } + if (options.preferEndOfPreviousEndSelection) { const entry = getTextEntryAtPath( @@ -525,8 +627,12 @@ const syncPeerSelectionAfterHistory = ( value, readEditorSelection(editor), { + preferDocumentEnd: + previousSelection && + isSelectionAtDocumentEnd(previousValue, previousSelection), preferEndOfPreviousEndSelection: previousSelection && + !isSelectionAtDocumentEnd(previousValue, previousSelection) && isSelectionAtTextEnd(previousValue, previousSelection) ? previousSelection : null, @@ -544,6 +650,7 @@ const syncPeerSelectionAfterHistory = ( name: peer.name, }) }) + editor.api.dom.focus({ retries: 1 }) network.syncAwareness() } @@ -649,9 +756,16 @@ const selectHello = ( peer: ExamplePeer, editor: CustomEditor ) => { + const entry = getFirstBlockTextEntry(editor, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) const range: Range = { - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: 5 }, + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, } editor.update((tx) => { @@ -665,31 +779,41 @@ const selectHello = ( } const appendText = (peer: ExamplePeer, editor: CustomEditor) => { - const text = getBlockText(editor, 0) - const offset = text.length + peer.appendText.length + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry) { + return + } + + const offset = entry.text.length + peer.appendText.length editor.update((tx) => { tx.text.insert(peer.appendText, { - at: { path: [0, 0], offset: text.length }, + at: { path: entry.path, offset: entry.text.length }, }) tx.selection.set({ - anchor: { path: [0, 0], offset }, - focus: { path: [0, 0], offset }, + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, }) }) } const insertExclamation = (editor: CustomEditor) => { - const text = getBlockText(editor, 0) - const offset = text.length + 1 + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry) { + return + } + + const offset = entry.text.length + 1 editor.update((tx) => { tx.text.insert('!', { - at: { path: [0, 0], offset: text.length }, + at: { path: entry.path, offset: entry.text.length }, }) tx.selection.set({ - anchor: { path: [0, 0], offset }, - focus: { path: [0, 0], offset }, + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, }) }) } @@ -707,12 +831,18 @@ const selectDefaultBoldRange = (editor: CustomEditor) => { return } - const length = Math.min(5, getBlockText(editor, 0).length) + const entry = getFirstBlockTextEntry(editor, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) editor.update((tx) => { tx.selection.set({ - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: length }, + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, }) }) } @@ -726,45 +856,24 @@ const toggleBold = (editor: CustomEditor) => { const replaceDocument = (peer: ExamplePeer, editor: CustomEditor) => { const value = getEditorValue(editor) - const [firstBlock] = value - const firstText = firstBlock ? NodeApi.string(firstBlock) : '' - const operations: Operation[] = [] - - for (let index = value.length - 1; index > 0; index--) { - operations.push({ - node: value[index]!, - path: [index], - root: 'main', - type: 'remove_node', - }) - } - - if (firstText.length > 0) { - operations.push({ - offset: 0, - path: [0, 0], - root: 'main', - text: firstText, - type: 'remove_text', - }) - } - - if (peer.replacementText.length > 0) { - operations.push({ - offset: 0, - path: [0, 0], - root: 'main', - text: peer.replacementText, - type: 'insert_text', - }) - } + const selection = { + anchor: { path: [0, 0], offset: peer.replacementText.length }, + focus: { path: [0, 0], offset: peer.replacementText.length }, + } satisfies Range editor.update((tx) => { - tx.operations.replay(operations) - tx.selection.set({ - anchor: { path: [0, 0], offset: peer.replacementText.length }, - focus: { path: [0, 0], offset: peer.replacementText.length }, - }) + tx.operations.replay([ + { + children: value, + index: 0, + newChildren: [paragraph(peer.replacementText)], + newSelection: selection, + path: [], + root: 'main', + selection: null, + type: 'replace_children', + }, + ]) }) } @@ -943,38 +1052,27 @@ const handleDeleteKeyDown = ( } const splitFirstText = (peer: ExamplePeer, editor: CustomEditor) => { - const text = getBlockText(editor, 0) - const offset = Math.max(1, Math.floor(text.length / 2)) - const [block] = getEditorValue(editor) + const value = getEditorValue(editor) + const [block] = value - if (!block || !('children' in block)) { + if (!block) { return } - const [leaf] = block.children + const entry = findFirstTextEntryInNode(block, [0]) - if (!leaf || !('text' in leaf)) { + if (!entry || entry.text.length < 2) { return } - const { children: _children, ...elementProperties } = block - const { text: _text, ...textProperties } = leaf + const offset = Math.max(1, Math.floor(entry.text.length / 2)) editor.update((tx) => { - tx.operations.replay([ - { - path: [0, 0], - position: offset, - properties: textProperties, - type: 'split_node', - }, - { - path: [0], - position: 1, - properties: elementProperties, - type: 'split_node', - }, - ]) + tx.selection.set({ + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, + }) + tx.break.insert() }) peer.renderEpoch += 1 } @@ -1002,19 +1100,24 @@ const ensureParagraphCount = (editor: CustomEditor, count: number) => { } const removeSecondBlock = (editor: CustomEditor) => { - ensureParagraphCount(editor, 2) + if (getParagraphCount(editor) < 2) { + return + } editor.update((tx) => { tx.nodes.remove({ at: [1] }) }) } -const mergeSecondBlock = (editor: CustomEditor) => { - ensureParagraphCount(editor, 2) +const mergeSecondBlock = (peer: ExamplePeer, editor: CustomEditor) => { + if (getParagraphCount(editor) < 2) { + return + } editor.update((tx) => { tx.nodes.merge({ at: [1] }) }) + peer.renderEpoch += 1 } const moveFirstBlockDown = (editor: CustomEditor) => { @@ -1025,13 +1128,19 @@ const moveFirstBlockDown = (editor: CustomEditor) => { }) } -const setFirstBlock = (editor: CustomEditor) => { +const setFirstBlockRole = (editor: CustomEditor) => { editor.update((tx) => { - tx.nodes.set({ role: 'title', type: 'heading-one' } as never, { at: [0] }) + tx.nodes.set({ role: 'title' } as never, { at: [0] }) }) } -const unsetFirstBlock = (editor: CustomEditor) => { +const unsetFirstBlockRole = (editor: CustomEditor) => { + const [firstBlock] = getEditorValue(editor) + + if (!firstBlock || !('role' in firstBlock)) { + return + } + editor.update((tx) => { tx.nodes.unset('role' as never, { at: [0] }) }) @@ -1045,7 +1154,7 @@ const firstBlockIsQuote = (editor: CustomEditor) => { const unwrapFirstBlock = (editor: CustomEditor) => { if (!firstBlockIsQuote(editor)) { - wrapFirstBlock(editor) + return } editor.update((tx) => { @@ -1055,7 +1164,7 @@ const unwrapFirstBlock = (editor: CustomEditor) => { const liftFirstWrappedBlock = (editor: CustomEditor) => { if (!firstBlockIsQuote(editor)) { - wrapFirstBlock(editor) + return } editor.update((tx) => { @@ -1064,12 +1173,16 @@ const liftFirstWrappedBlock = (editor: CustomEditor) => { } const insertFragmentText = (peer: ExamplePeer, editor: CustomEditor) => { - const text = getBlockText(editor, 0) + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry) { + return + } editor.update((tx) => { tx.selection.set({ - anchor: { path: [0, 0], offset: text.length }, - focus: { path: [0, 0], offset: text.length }, + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, }) tx.fragment.insert([{ text: `${peer.name} fragment` }]) }) @@ -1086,8 +1199,13 @@ const moveFirstBlockAfterSecond = (editor: CustomEditor) => { } const deleteFirstFragment = (editor: CustomEditor) => { - const text = getBlockText(editor, 0) - const length = Math.min(5, text.length) + const entry = getFirstBlockTextEntry(editor, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) if (length === 0) { return @@ -1095,24 +1213,24 @@ const deleteFirstFragment = (editor: CustomEditor) => { editor.update((tx) => { tx.selection.set({ - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: length }, + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, }) tx.fragment.delete() }) } const deleteBackwardFromFirstBlockEnd = (editor: CustomEditor) => { - const text = getBlockText(editor, 0) + const entry = getFirstBlockTextEntry(editor, 'last') - if (text.length === 0) { + if (!entry || entry.text.length === 0) { return } editor.update((tx) => { tx.selection.set({ - anchor: { path: [0, 0], offset: text.length }, - focus: { path: [0, 0], offset: text.length }, + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, }) tx.text.deleteBackward({ unit: 'character' }) }) @@ -1513,7 +1631,7 @@ const PeerPanel = ({ runPeerCommand(network, peer, editor, () => - mergeSecondBlock(editor) + mergeSecondBlock(peer, editor) ) } testId={`yjs-peer-${peer.id}-merge-node`} @@ -1532,21 +1650,23 @@ const PeerPanel = ({ - runPeerCommand(network, peer, editor, () => setFirstBlock(editor)) + runPeerCommand(network, peer, editor, () => + setFirstBlockRole(editor) + ) } testId={`yjs-peer-${peer.id}-set-node`} > - Set + Set Role runPeerCommand(network, peer, editor, () => - unsetFirstBlock(editor) + unsetFirstBlockRole(editor) ) } testId={`yjs-peer-${peer.id}-unset-node`} > - Unset + Unset Role @@ -1633,6 +1753,9 @@ const PeerPanel = ({
+ handleHistoryKeyDown(event, network, peer, editor) + } > Date: Sun, 7 Jun 2026 21:18:00 +0800 Subject: [PATCH 04/11] fix(yjs): repair structural paths and offline split undo convergence Harden the @slate/yjs controller and operations so concurrent wrap, move, merge, unwrap, and reconnect edits resolve to consistent Yjs paths, and fix offline split undo/redo convergence. Adds contract and structural soak tests covering split/merge, operation exhaustiveness, provider, and React bindings. --- .changeset/slate-yjs-offline-split-undo.md | 5 + packages/slate-yjs/package.json | 4 +- packages/slate-yjs/src/core/controller.ts | 898 ++++++++++++++++-- packages/slate-yjs/src/core/document.ts | 108 ++- packages/slate-yjs/src/core/operations.ts | 129 ++- packages/slate-yjs/src/core/types.ts | 47 + packages/slate-yjs/src/react/index.ts | 388 +++++++- .../test/merge-node-contract.spec.ts | 38 + .../operation-exhaustiveness-contract.spec.ts | 36 + .../test/package-config-contract.spec.ts | 15 + .../slate-yjs/test/provider-contract.spec.ts | 870 +++++++++++++++++ .../slate-yjs/test/react-contract.spec.tsx | 416 ++++++++ .../test/split-merge-contract.spec.ts | 214 +++++ .../test/split-node-contract.spec.ts | 115 ++- .../test/structural-soak-contract.spec.ts | 536 +++++++++++ .../slate-yjs/test/support/collaboration.ts | 21 +- packages/slate-yjs/tsconfig.json | 3 +- .../examples/yjs-collaboration.test.ts | 519 +++++++++- 18 files changed, 4259 insertions(+), 103 deletions(-) create mode 100644 .changeset/slate-yjs-offline-split-undo.md create mode 100644 packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts create mode 100644 packages/slate-yjs/test/provider-contract.spec.ts create mode 100644 packages/slate-yjs/test/react-contract.spec.tsx create mode 100644 packages/slate-yjs/test/split-merge-contract.spec.ts create mode 100644 packages/slate-yjs/test/structural-soak-contract.spec.ts diff --git a/.changeset/slate-yjs-offline-split-undo.md b/.changeset/slate-yjs-offline-split-undo.md new file mode 100644 index 0000000000..0cf2cd7198 --- /dev/null +++ b/.changeset/slate-yjs-offline-split-undo.md @@ -0,0 +1,5 @@ +--- +"@slate/yjs": patch +--- + +Fix offline split undo/redo convergence and structural Yjs path repair for concurrent wrap, move, merge, unwrap, and reconnect edits. diff --git a/packages/slate-yjs/package.json b/packages/slate-yjs/package.json index a579f5a001..fce8f531dc 100644 --- a/packages/slate-yjs/package.json +++ b/packages/slate-yjs/package.json @@ -47,11 +47,13 @@ "@types/react": "^19.2.14", "react": "^19.2.5", "slate": "workspace:*", - "slate-history": "workspace:*" + "slate-history": "workspace:*", + "slate-react": "workspace:*" }, "peerDependencies": { "react": ">=19.2.0", "slate": ">=0.124.2", + "slate-react": ">=0.124.2", "yjs": "13.6.30" }, "keywords": [ diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts index fcaa51e11c..d7fb241a57 100644 --- a/packages/slate-yjs/src/core/controller.ts +++ b/packages/slate-yjs/src/core/controller.ts @@ -3,11 +3,13 @@ import type { Editor, EditorCommit, EditorSnapshot, + Element, Operation, Path, Range, } from 'slate' -import { NodeApi } from 'slate' +import { createEditor, NodeApi, OperationApi } from 'slate' +import { Editor as EditorApi } from 'slate/internal' import * as Y from 'yjs' import { @@ -24,6 +26,7 @@ import { readSlateValueFromYjs, removeYjsChild, replaceYjsChildren, + SPLIT_UNDO_TEXT_ATTRIBUTE, } from './document' import { applySlateOperationToYjs, @@ -33,6 +36,10 @@ import type { YjsAwarenessChange, YjsAwarenessLike, YjsExtensionOptions, + YjsProviderLike, + YjsProviderStatus, + YjsProviderStatusPayload, + YjsProviderSyncedPayload, YjsRemoteCursor, YjsState, YjsTraceEntry, @@ -44,16 +51,97 @@ import { } from './undo-manager-adapter' type SplitHistory = { + absorbedRemoteSplit?: boolean elementPath: Path elementPosition: number elementProperties: Record rightText: string textPath: Path textProperties: Record + undoneWhileDisconnected?: boolean +} + +type PendingTextSplitHistory = Omit< + SplitHistory, + 'elementPosition' | 'elementProperties' +> + +type HistoryBatchLike = { + operations?: Operation[] + statePatches?: unknown[] +} + +type HistoryLike = { + redos?: HistoryBatchLike[] + undos?: HistoryBatchLike[] } const SPLIT_HISTORY_META = 'slate-yjs:split-history' +const operationsEqual = (a: Operation, b: Operation | undefined) => + !!b && JSON.stringify(a) === JSON.stringify(b) + +const normalizeProviderStatus = ( + value: YjsProviderStatusPayload | unknown +): YjsProviderStatus | null => { + if (typeof value === 'string') { + return value + } + + if ( + value && + typeof value === 'object' && + 'status' in value && + typeof value.status === 'string' + ) { + return value.status + } + + return null +} + +const normalizeProviderSynced = ( + value: YjsProviderSyncedPayload | unknown +): boolean | null => { + if (typeof value === 'boolean') { + return value + } + + if ( + value && + typeof value === 'object' && + 'state' in value && + typeof value.state === 'boolean' + ) { + return value.state + } + + if ( + value && + typeof value === 'object' && + 'synced' in value && + typeof value.synced === 'boolean' + ) { + return value.synced + } + + return null +} + +const readProviderStatus = (provider: YjsProviderLike | undefined) => + normalizeProviderStatus(provider?.status) + +const readProviderSynced = (provider: YjsProviderLike | undefined) => + normalizeProviderSynced(provider?.synced) + +const isPromiseLike = (value: unknown): value is PromiseLike => + Boolean( + value && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' + ) + const remoteImportOptions = { metadata: { collab: { origin: 'remote', saveToHistory: false }, @@ -71,15 +159,23 @@ export class YjsController { private readonly awarenessSelectionField: string private readonly awarenessSubscribers = new Set<() => void>() private readonly clientId: number | string + private readonly destroyProviderOnUnmount: boolean private readonly doc: Y.Doc private readonly editor: Editor private readonly historyOrigin = {} private readonly localOrigin = {} + private readonly seedOrigin = {} private readonly observer: ( events: Y.YEvent[], transaction: Y.Transaction ) => void + private readonly provider?: YjsProviderLike + private readonly providerOwnedDoc: boolean + private readonly providerStatusObserver: (status: unknown) => void + private readonly providerSubscribers = new Set<() => void>() + private readonly providerSyncedObserver: (synced: unknown) => void private readonly root: Y.XmlElement + private readonly seedProviderOnSync: boolean private readonly traceEntries: YjsTraceEntry[] = [] private readonly undoManager: Y.UndoManager private readonly undoManagerAdapter: ReturnType< @@ -90,26 +186,68 @@ export class YjsController { private connected = true private importing = false private paused = false + private pendingTextSplitHistory: PendingTextSplitHistory | null = null + private providerRevision = 0 + private providerStatusValue: YjsProviderStatus | null + private providerSyncedValue: boolean | null constructor(editor: Editor, options: YjsExtensionOptions) { this.editor = editor - this.doc = options.doc ?? new Y.Doc() + this.provider = options.provider + this.providerOwnedDoc = + !!this.provider && (!!options.doc || !!this.provider.doc) + this.doc = options.doc ?? this.provider?.doc ?? new Y.Doc() this.root = this.doc.get(options.rootName ?? 'slate', Y.XmlElement) this.clientId = options.clientId ?? this.doc.clientID - this.awareness = options.awareness + this.destroyProviderOnUnmount = options.destroyProviderOnUnmount ?? false + this.seedProviderOnSync = options.seedProviderOnSync ?? true + this.awareness = options.awareness ?? this.provider?.awareness this.awarenessDataField = options.awarenessDataField ?? 'data' this.awarenessSelectionField = options.awarenessSelectionField ?? 'selection' this.autoSendSelection = options.autoSendSelection ?? true + this.providerStatusValue = readProviderStatus(this.provider) + this.providerSyncedValue = readProviderSynced(this.provider) + this.connected = this.connectedFromProviderStatus( + this.providerStatusValue, + this.connected + ) this.awarenessObserver = () => { this.updateAwarenessRevision() } + this.providerStatusObserver = (payload) => { + const status = normalizeProviderStatus(payload) + + if (status) { + this.updateProviderStatus(status) + } + } + this.providerSyncedObserver = (payload) => { + const synced = + normalizeProviderSynced(payload) ?? readProviderSynced(this.provider) + + if (synced !== null) { + this.updateProviderSynced(synced) + } + } this.undoManager = new Y.UndoManager(this.root, { trackedOrigins: new Set([this.localOrigin]), }) this.undoManagerAdapter = createYjsUndoManagerAdapter(this.undoManager) this.observer = (_events, transaction) => { - if (transaction.origin === this.localOrigin || this.paused) { + if ( + transaction.origin === this.localOrigin || + transaction.origin === this.seedOrigin || + this.paused + ) { + return + } + + if (transaction.origin === this.historyOrigin) { + this.importFromYjs('remote-reconcile', { + repairRemoteSplitAfterOfflineUndo: false, + }) + return } @@ -117,10 +255,22 @@ export class YjsController { } this.awareness?.on?.('change', this.awarenessObserver) + this.provider?.on?.('status', this.providerStatusObserver) + this.provider?.on?.('sync', this.providerSyncedObserver) + this.provider?.on?.('synced', this.providerSyncedObserver) } destroy() { this.awareness?.off?.('change', this.awarenessObserver) + this.provider?.off?.('status', this.providerStatusObserver) + this.provider?.off?.('sync', this.providerSyncedObserver) + this.provider?.off?.('synced', this.providerSyncedObserver) + if (this.provider) { + this.clearSelection() + } + if (this.destroyProviderOnUnmount) { + this.provider?.destroy?.() + } this.root.unobserveDeep(this.observer) this.undoManager.destroy() } @@ -136,7 +286,6 @@ export class YjsController { ) { return } - const shouldSendSelection = this.autoSendSelection && commit.operations.some((operation) => operation.type === 'set_selection') @@ -163,6 +312,21 @@ export class YjsController { return } + if (this.shouldRejectUnsafeProviderCommit()) { + this.removeRejectedOperationsFromHistory(operations) + this.replaceEditorValue( + this.readChildrenBeforeOperations(operations), + commit.selectionBefore as Range | null + ) + this.removeRejectedOperationsFromHistory(operations) + this.removeRejectedOperationsFromHistoryAfterCommit(operations) + + return + } + if (this.shouldSeedEmptyProviderDocForCommit()) { + this.seedValue(this.readChildrenBeforeOperations(operations)) + } + const splitHistory = this.createSplitHistory(operations) this.undoManager.stopCapturing() @@ -181,14 +345,9 @@ export class YjsController { seed() { if (this.root.length === 0) { - const children = this.editor.read((state) => [ - ...state.value.get().roots.main, - ]) as Descendant[] - - this.doc.transact(() => { - replaceYjsChildren(this.root, children) - }, {}) - this.traceEntries.push({ mode: 'seed' }) + if (this.shouldSeedInitialProviderDoc()) { + this.seedInitialValue() + } } else { this.importFromYjs('seed') } @@ -203,10 +362,14 @@ export class YjsController { connected: () => this.connected, doc: () => this.doc, paused: () => this.paused, + providerRevision: () => this.providerRevision, + providerStatus: () => this.providerStatusValue, + providerSynced: () => this.providerSyncedValue, remoteCursor: (clientId) => this.remoteCursor(clientId), remoteCursors: () => this.remoteCursors(), root: () => this.root, subscribeAwareness: (listener) => this.subscribeAwareness(listener), + subscribeProvider: (listener) => this.subscribeProvider(listener), trace: () => [...this.traceEntries], } } @@ -220,18 +383,19 @@ export class YjsController { this.traceEntries.length = 0 }, connect: () => { - this.connected = true - this.updateAwarenessRevision() + this.connect() }, disconnect: () => { - this.connected = false - this.updateAwarenessRevision() + this.disconnect() }, pause: () => { this.paused = true }, reconcile: () => { - this.importFromYjs() + this.reconcile() + }, + reconnect: () => { + this.reconnect() }, redo: () => { if (!this.redoSplit()) { @@ -263,6 +427,14 @@ export class YjsController { } } + private subscribeProvider(listener: () => void) { + this.providerSubscribers.add(listener) + + return () => { + this.providerSubscribers.delete(listener) + } + } + private updateAwarenessRevision() { this.awarenessRevision += 1 @@ -271,13 +443,329 @@ export class YjsController { } } + private updateProviderRevision() { + this.providerRevision += 1 + + for (const listener of this.providerSubscribers) { + listener() + } + } + + private updateProviderStatus(status: YjsProviderStatus) { + this.updateConnectedFromProviderStatus(status) + + if (this.providerStatusValue === status) { + return + } + + this.providerStatusValue = status + this.updateProviderRevision() + } + + private updateConnectedFromProviderStatus(status: YjsProviderStatus) { + const connected = this.connectedFromProviderStatus(status, this.connected) + + this.setConnected(connected) + } + + private connectedFromProviderStatus( + status: YjsProviderStatus | null, + fallback: boolean + ) { + if (status === 'connected') { + return true + } + + if (status === 'connecting' || status === 'disconnected') { + return false + } + + return fallback + } + + private syncProviderLifecycleStatus(fallbackConnected: boolean) { + const status = readProviderStatus(this.provider) + + if (status) { + if (!fallbackConnected && status === 'connected') { + return + } + + this.updateProviderStatus(status) + + return + } + + if (this.providerStatusValue === null) { + this.setConnected(fallbackConnected) + } + } + + private setConnected(connected: boolean) { + if (this.connected === connected) { + return + } + + this.connected = connected + this.updateAwarenessRevision() + } + + private updateProviderSynced(synced: boolean) { + if (this.providerSyncedValue === synced) { + return + } + + this.providerSyncedValue = synced + this.reconcileProviderOwnedDocAfterSync() + this.updateProviderRevision() + } + + private connect() { + if (this.provider) { + const result = this.provider.connect?.() + + if (isPromiseLike(result)) { + void result.then( + () => { + this.syncProviderLifecycleStatus(true) + }, + () => undefined + ) + } else { + this.syncProviderLifecycleStatus(true) + } + + return result + } + + this.setConnected(true) + } + + private disconnect() { + if (this.provider) { + this.setConnected(false) + const result = this.provider.disconnect?.() + + if (isPromiseLike(result)) { + void result.then( + () => { + this.syncProviderLifecycleStatus(false) + }, + () => undefined + ) + } else { + this.syncProviderLifecycleStatus(false) + } + + return result + } + + this.setConnected(false) + } + + private reconnect() { + const result = this.disconnect() + + if (isPromiseLike(result)) { + void result.then( + () => { + this.connect() + }, + () => undefined + ) + + return + } + + this.connect() + } + + private reconcile() { + if (this.providerOwnedDoc && this.root.length === 0) { + this.reconcileProviderOwnedDocAfterSync() + + return + } + + this.importFromYjs() + } + + private removeRejectedOperationsFromHistory( + operations: readonly Operation[] + ) { + const history = this.readHistory() + + if (!history) { + return + } + + this.removeRejectedOperationsFromHistoryStack(history.undos, operations) + this.removeRejectedOperationsFromHistoryStack(history.redos, operations) + } + + private removeRejectedOperationsFromHistoryAfterCommit( + operations: readonly Operation[] + ) { + const remove = () => { + this.removeRejectedOperationsFromHistory(operations) + } + + if (typeof queueMicrotask === 'function') { + queueMicrotask(remove) + } else { + void Promise.resolve().then(remove) + } + } + + private readHistory(): HistoryLike | null { + return this.editor.read((state) => { + const history = (state as any).history + + if (!history) { + return null + } + + return { + redos: history.redos?.(), + undos: history.undos?.(), + } + }) + } + + private removeRejectedOperationsFromHistoryStack( + stack: HistoryBatchLike[] | undefined, + operations: readonly Operation[] + ) { + if (!stack || operations.length === 0) { + return + } + + for (let batchIndex = stack.length - 1; batchIndex >= 0; batchIndex -= 1) { + const batch = stack[batchIndex] + const batchOperations = batch?.operations + + if (!Array.isArray(batchOperations)) { + throw new Error('Cannot remove rejected Yjs operations from history.') + } + + if (batchOperations.length < operations.length) { + continue + } + + const start = batchOperations.length - operations.length + + if ( + operations.every((operation, index) => + operationsEqual(operation, batchOperations[start + index]) + ) + ) { + batchOperations.splice(start, operations.length) + + if ( + batchOperations.length === 0 && + (batch.statePatches?.length ?? 0) === 0 + ) { + stack.splice(batchIndex, 1) + } + + return + } + } + } + + private shouldDeferProviderSeed() { + return ( + this.providerOwnedDoc && + this.providerSyncedValue !== true && + this.root.length === 0 + ) + } + + private shouldSeedEmptyProviderDocForCommit() { + return ( + this.providerOwnedDoc && + this.seedProviderOnSync && + this.providerSyncedValue === true && + this.root.length === 0 + ) + } + + private shouldSeedInitialProviderDoc() { + return ( + (!this.providerOwnedDoc || this.seedProviderOnSync) && + !this.shouldDeferProviderSeed() + ) + } + + private shouldRejectUnsafeProviderCommit() { + return ( + this.providerOwnedDoc && + this.root.length === 0 && + (!this.seedProviderOnSync || this.providerSyncedValue !== true) + ) + } + + private shouldWaitForAppSeededProviderDoc() { + return this.providerOwnedDoc && this.root.length === 0 + } + + private readEditorChildren() { + return this.editor.read((state) => [ + ...state.value.get().roots.main, + ]) as Element[] + } + + private readChildrenBeforeOperations(operations: readonly Operation[]) { + const baselineEditor = createEditor() + + EditorApi.replace(baselineEditor, { + children: this.readEditorChildren(), + marks: null, + selection: null, + }) + baselineEditor.update((tx) => { + tx.operations.replay([...operations].reverse().map(OperationApi.inverse)) + }) + + return EditorApi.getSnapshot(baselineEditor).children as Element[] + } + + private seedInitialValue() { + this.seedValue(this.readEditorChildren()) + } + + private seedValue(children: Descendant[]) { + this.doc.transact(() => { + replaceYjsChildren(this.root, children) + }, this.seedOrigin) + this.traceEntries.push({ mode: 'seed' }) + } + + private reconcileProviderOwnedDocAfterSync() { + if (!this.providerOwnedDoc || this.providerSyncedValue !== true) { + return + } + + if (this.root.length === 0) { + if (this.seedProviderOnSync) { + this.seedInitialValue() + } + } else { + this.importFromYjs('seed') + } + } + private clearSelection() { if (!this.awareness) { return } + const localState = this.awareness.getLocalState() + if ( - this.awareness.getLocalState()?.[this.awarenessSelectionField] !== null + localState && + this.awarenessSelectionField in localState && + localState[this.awarenessSelectionField] !== null ) { this.awareness.setLocalStateField(this.awarenessSelectionField, null) } @@ -355,13 +843,20 @@ export class YjsController { if (!this.awareness) { return } + if ( + this.shouldDeferProviderSeed() || + this.shouldWaitForAppSeededProviderDoc() + ) { + return + } if (data !== undefined) { this.sendCursorData(data) } - const nextSelection = range - ? createYjsAwarenessSelection(this.root, range) + const nextRange = range ? this.sanitizeYjsSelection(range) : null + const nextSelection = nextRange + ? createYjsAwarenessSelection(this.root, nextRange) : null const currentSelection = this.awareness.getLocalState()?.[this.awarenessSelectionField] @@ -374,6 +869,22 @@ export class YjsController { } } + private sanitizeYjsSelection(range: Range): Range | null { + for (const point of [range.anchor, range.focus]) { + const node = getYjsNodeIf(this.root, point.path) + + if ( + !(node instanceof Y.XmlText) || + point.offset < 0 || + point.offset > getYjsLength(node) + ) { + return null + } + } + + return range + } + private applyOperation(operation: Operation) { const trace = applySlateOperationToYjs(this.root, operation) @@ -405,35 +916,62 @@ export class YjsController { } ) - if (!textSplit) { - return null - } - - const elementPath = textSplit.path.slice(0, -1) const elementSplit = operations.find( (operation): operation is Extract => operation.type === 'split_node' && - pathsEqual(operation.path, elementPath) + !( + operation.path.length > 0 && + getYjsNodeIf(this.root, operation.path) instanceof Y.XmlText + ) ) - if (!elementSplit) { + if (!textSplit) { + const pendingTextSplitHistory = this.pendingTextSplitHistory + + this.pendingTextSplitHistory = null + + if ( + elementSplit && + pendingTextSplitHistory && + pathsEqual(elementSplit.path, pendingTextSplitHistory.elementPath) + ) { + return { + ...pendingTextSplitHistory, + elementPosition: elementSplit.position, + elementProperties: elementSplit.properties as Record, + } + } + return null } + const elementPath = textSplit.path.slice(0, -1) const text = getYjsNode(this.root, textSplit.path) if (!(text instanceof Y.XmlText)) { return null } - return { + const pendingTextSplitHistory: PendingTextSplitHistory = { elementPath, - elementPosition: elementSplit.position, - elementProperties: elementSplit.properties as Record, rightText: getYjsTextContent(text).slice(textSplit.position), textPath: textSplit.path, textProperties: textSplit.properties as Record, } + + if (!elementSplit || !pathsEqual(elementSplit.path, elementPath)) { + this.pendingTextSplitHistory = pendingTextSplitHistory + + return null + } + + this.pendingTextSplitHistory = null + + return { + ...pendingTextSplitHistory, + elementPosition: elementSplit.position, + elementProperties: elementSplit.properties as Record, + } } private peekSplit(item: YjsUndoManagerStackItem | null): { @@ -458,6 +996,12 @@ export class YjsController { return false } + if (redo.splitHistory.absorbedRemoteSplit) { + this.undoManagerAdapter.moveRedoToUndo(redo.item) + + return true + } + this.doc.transact(() => { const text = getYjsNode(this.root, redo.splitHistory.textPath) @@ -502,6 +1046,24 @@ export class YjsController { this.undoManagerAdapter.storeUndoMeta(SPLIT_HISTORY_META, splitHistory) } + private replaceEditorValue(children: Descendant[], selection: Range | null) { + const nextSelection = this.sanitizeImportSelection(children, selection) + + this.importing = true + + try { + this.editor.update((tx) => { + tx.value.replace({ + children, + marks: null, + selection: nextSelection, + }) + }, remoteImportOptions) + } finally { + this.importing = false + } + } + private undoSplit() { const undo = this.peekSplit(this.undoManagerAdapter.peekUndo()) @@ -511,6 +1073,13 @@ export class YjsController { return false } + if (undo.splitHistory.absorbedRemoteSplit) { + this.undoManagerAdapter.moveUndoToRedo(undo.item) + + return true + } + + const undoneWhileDisconnected = !this.connected let rightText = undo.splitHistory.rightText this.doc.transact(() => { @@ -528,37 +1097,34 @@ export class YjsController { ) } - rightText = appendElementText(this.root, leftText, rightElement) + rightText = appendElementText(this.root, leftText, rightElement, { + [SPLIT_UNDO_TEXT_ATTRIBUTE]: undoneWhileDisconnected, + }) removeYjsChild(this.root, parent, index) }, this.historyOrigin) undo.splitHistory.rightText = rightText + undo.splitHistory.undoneWhileDisconnected = undoneWhileDisconnected this.undoManagerAdapter.moveUndoToRedo(undo.item) return true } - private importFromYjs(mode: YjsTraceEntry['mode'] = 'remote-reconcile') { + private importFromYjs( + mode: YjsTraceEntry['mode'] = 'remote-reconcile', + options: { repairRemoteSplitAfterOfflineUndo?: boolean } = {} + ) { + if (options.repairRemoteSplitAfterOfflineUndo ?? true) { + this.repairRemoteSplitAfterOfflineUndo() + } + const children = readSlateValueFromYjs(this.root) - const selection = this.sanitizeImportSelection( + + this.traceEntries.push({ mode }) + this.replaceEditorValue( children, this.editor.read((state) => state.selection.get()) as Range | null ) - - this.traceEntries.push({ mode }) - this.importing = true - - try { - this.editor.update((tx) => { - tx.value.replace({ - children, - marks: null, - selection, - }) - }, remoteImportOptions) - } finally { - this.importing = false - } } private sanitizeImportSelection( @@ -586,9 +1152,109 @@ export class YjsController { return selection } + + private repairRemoteSplitAfterOfflineUndo() { + const repairs = findSplitUndoTextRepairs(this.root) + const redo = this.peekSplit(this.undoManagerAdapter.peekRedo()) + const splitHistory = redo?.splitHistory + const activeRepair = splitHistory?.undoneWhileDisconnected + ? this.getSplitUndoTextRepair(splitHistory) + : null + + if (repairs.length > 0) { + this.doc.transact(() => { + for (const repair of repairs) { + if (repair.hasRemoteSplitBoundary) { + repair.text.delete(repair.offset, repair.length) + } else { + clearSplitUndoTextAttribute( + repair.text, + repair.offset, + repair.length + ) + } + } + }, this.historyOrigin) + } + + if (!splitHistory?.undoneWhileDisconnected) { + return + } + + if ( + activeRepair?.hasRemoteSplitBoundary || + (!activeRepair && + this.hasRemoteSplitBoundary(splitHistory) && + !this.leftTextEndsWithSplitRightText(splitHistory)) + ) { + splitHistory.absorbedRemoteSplit = true + } else { + splitHistory.undoneWhileDisconnected = false + } + } + + private getSplitUndoTextRepair(splitHistory: SplitHistory) { + if (splitHistory.rightText.length === 0) { + return null + } + + try { + const leftText = getYjsNode(this.root, splitHistory.textPath) + + if (!(leftText instanceof Y.XmlText)) { + return null + } + + const trailing = getTrailingSplitUndoText(leftText) + + if (!trailing || trailing.value !== splitHistory.rightText) { + return null + } + + return { + ...trailing, + hasRemoteSplitBoundary: this.hasRemoteSplitBoundary(splitHistory), + text: leftText, + } as const + } catch { + return null + } + } + + private hasRemoteSplitBoundary(splitHistory: SplitHistory) { + try { + const rightElement = getYjsNode( + this.root, + nextPath(splitHistory.elementPath) + ) + + return getVisibleText(this.root, rightElement).startsWith( + splitHistory.rightText + ) + } catch { + return false + } + } + + private leftTextEndsWithSplitRightText(splitHistory: SplitHistory) { + try { + const leftText = getYjsNode(this.root, splitHistory.textPath) + + return ( + leftText instanceof Y.XmlText && + getYjsTextContent(leftText).endsWith(splitHistory.rightText) + ) + } catch { + return false + } + } } -const appendTextContent = (target: Y.XmlText, source: Y.XmlText) => { +const appendTextContent = ( + target: Y.XmlText, + source: Y.XmlText, + extraAttributes: Record = {} +) => { let offset = getYjsLength(target) let insertedText = '' @@ -597,7 +1263,10 @@ const appendTextContent = (target: Y.XmlText, source: Y.XmlText) => { continue } - target.insert(offset, delta.insert, delta.attributes) + target.insert(offset, delta.insert, { + ...(delta.attributes ?? {}), + ...extraAttributes, + }) offset += delta.insert.length insertedText += delta.insert } @@ -608,21 +1277,134 @@ const appendTextContent = (target: Y.XmlText, source: Y.XmlText) => { const appendElementText = ( root: Y.XmlElement, target: Y.XmlText, - element: Y.XmlElement + element: Y.XmlElement, + extraAttributes: Record = {} ) => { let insertedText = '' for (const child of getYjsVisibleChildren(root, element)) { if (child instanceof Y.XmlText) { - insertedText += appendTextContent(target, child) + insertedText += appendTextContent(target, child, extraAttributes) } else { - insertedText += appendElementText(root, target, child) + insertedText += appendElementText(root, target, child, extraAttributes) } } return insertedText } +const findLastVisibleText = ( + root: Y.XmlElement, + node: Y.XmlElement | Y.XmlText +): Y.XmlText | null => { + if (node instanceof Y.XmlText) { + return node + } + + const children = getYjsVisibleChildren(root, node) + + for (let index = children.length - 1; index >= 0; index--) { + const child = children[index] + const text = child ? findLastVisibleText(root, child) : null + + if (text) { + return text + } + } + + return null +} + +const getTrailingSplitUndoText = (text: Y.XmlText) => { + let offset = getYjsLength(text) + let value = '' + + for (const delta of [...text.toDelta()].reverse()) { + if (typeof delta.insert !== 'string' || delta.insert.length === 0) { + return value ? { length: value.length, offset, value } : null + } + + if (delta.attributes?.[SPLIT_UNDO_TEXT_ATTRIBUTE] === true) { + offset -= delta.insert.length + value = delta.insert + value + continue + } + + break + } + + return value ? { length: value.length, offset, value } : null +} + +const clearSplitUndoTextAttribute = ( + text: Y.XmlText, + offset: number, + length: number +) => { + text.format(offset, length, { + [SPLIT_UNDO_TEXT_ATTRIBUTE]: null, + } as unknown as Record) +} + +const getVisibleText = ( + root: Y.XmlElement, + node: Y.XmlElement | Y.XmlText +): string => { + if (node instanceof Y.XmlText) { + return getYjsTextContent(node) + } + + return getYjsVisibleChildren(root, node) + .map((child) => getVisibleText(root, child)) + .join('') +} + +const findSplitUndoTextRepairs = (root: Y.XmlElement) => { + const repairs: Array<{ + hasRemoteSplitBoundary: boolean + length: number + offset: number + text: Y.XmlText + }> = [] + + const visit = (parent: Y.XmlElement) => { + const children = getYjsVisibleChildren(root, parent) + + for (let index = 0; index < children.length; index++) { + const left = children[index] + + if (!(left instanceof Y.XmlElement)) { + continue + } + + const leftText = findLastVisibleText(root, left) + const right = children[index + 1] + const trailing = leftText ? getTrailingSplitUndoText(leftText) : null + + if (leftText && trailing) { + repairs.push({ + hasRemoteSplitBoundary: right + ? getVisibleText(root, right).startsWith(trailing.value) + : false, + length: trailing.length, + offset: trailing.offset, + text: leftText, + }) + } + } + + for (const child of children) { + if (child instanceof Y.XmlElement) { + visit(child) + } + } + } + + visit(root) + + return repairs +} + const isSplitHistory = (value: unknown): value is SplitHistory => typeof value === 'object' && value !== null && @@ -641,5 +1423,13 @@ const nextPath = (path: Path) => { return [...path.slice(0, -1), index + 1] } +const getYjsNodeIf = (root: Y.XmlElement, path: Path) => { + try { + return getYjsNode(root, path) + } catch { + return null + } +} + const pathsEqual = (a: Path, b: Path) => a.length === b.length && a.every((part, index) => part === b[index]) diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts index 664cbc2e72..382e8ff09b 100644 --- a/packages/slate-yjs/src/core/document.ts +++ b/packages/slate-yjs/src/core/document.ts @@ -4,6 +4,7 @@ import * as Y from 'yjs' const SLATE_TYPE_ATTRIBUTE = 'slate:type' const HIDDEN_ATTRIBUTE = 'slate:yjs-hidden' const NODE_ID_ATTRIBUTE = 'slate:yjs-id' +export const SPLIT_UNDO_TEXT_ATTRIBUTE = 'slate:yjs-split-undo-text' const VIRTUAL_CHILD_ID_ATTRIBUTE = 'slate:yjs-virtual-child-id' const VIRTUAL_PLACEHOLDER_ATTRIBUTE = 'slate:yjs-virtual-placeholder' @@ -49,17 +50,36 @@ const removeAttribute = (node: Y.XmlElement | Y.XmlText, attribute: string) => { node.removeAttribute(attribute) } -const isVirtualPlaceholder = ( +export const isVirtualYjsPlaceholder = ( node: Y.XmlElement | Y.XmlText ): node is Y.XmlElement => node instanceof Y.XmlElement && getAttributes(node)[VIRTUAL_PLACEHOLDER_ATTRIBUTE] === true -const getVirtualChild = (root: Y.XmlElement, node: Y.XmlElement) => { +export const getVirtualYjsChild = ( + root: Y.XmlElement, + node: Y.XmlElement, + visited = new Set() +): Y.XmlElement | Y.XmlText | null => { + if (visited.has(node)) { + return null + } + + visited.add(node) + const virtualChildId = node.getAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) if (typeof virtualChildId === 'string') { - return findYjsNodeById(root, virtualChildId) + const virtualChild = findYjsNodeById(root, virtualChildId) + + if ( + virtualChild instanceof Y.XmlElement && + isVirtualYjsPlaceholder(virtualChild) + ) { + return getVirtualYjsChild(root, virtualChild, visited) + } + + return virtualChild } return null @@ -71,8 +91,8 @@ const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { return [] } - if (isVirtualPlaceholder(child)) { - const virtualChild = getVirtualChild(root, child) + if (isVirtualYjsPlaceholder(child)) { + const virtualChild = getVirtualYjsChild(root, child) return virtualChild ? [{ node: virtualChild, rawIndex }] : [] } @@ -80,8 +100,8 @@ const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { return [{ node: child, rawIndex }] }) - if (!isVirtualPlaceholder(node)) { - const virtualChild = getVirtualChild(root, node) + if (!isVirtualYjsPlaceholder(node)) { + const virtualChild = getVirtualYjsChild(root, node) if (virtualChild) { return [{ node: virtualChild, rawIndex: -1 }, ...rawSlots] @@ -175,11 +195,16 @@ export const replaceYjsChildren = ( } } -export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => - getYjsVisibleChildren(root, root).map((node) => +export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => { + const children = getYjsVisibleChildren(root, root).map((node) => readSlateNodeFromYjs(root, node) ) + return children.length > 0 + ? children + : [{ children: [{ text: '' }], type: 'paragraph' }] +} + const getUniformTextAttributes = (node: Y.XmlText) => { const delta = node.toDelta() let attributes: Record | undefined @@ -189,7 +214,9 @@ const getUniformTextAttributes = (node: Y.XmlText) => { continue } - const partAttributes = part.attributes ?? {} + const partAttributes = { ...(part.attributes ?? {}) } + + deleteInternalAttributes(partAttributes) if (!attributes) { attributes = partAttributes @@ -232,31 +259,56 @@ const readSlateNodeFromYjs = ( delete attributes[SLATE_TYPE_ATTRIBUTE] deleteInternalAttributes(attributes) + const children = getYjsVisibleChildren(root, node).map((child) => + readSlateNodeFromYjs(root, child) + ) + return { ...attributes, type, - children: getYjsVisibleChildren(root, node).map((child) => - readSlateNodeFromYjs(root, child) - ), + children: children.length > 0 ? children : [{ text: '' }], } as Descendant } -export const cloneYjsNode = ( - node: Y.XmlElement | Y.XmlText -): Y.XmlElement | Y.XmlText => { +const cloneYjsNodeWithRoot = ( + node: Y.XmlElement | Y.XmlText, + root: Y.XmlElement | null +): Y.XmlElement | Y.XmlText | null => { + if (root && node instanceof Y.XmlElement && isVirtualYjsPlaceholder(node)) { + const virtualChild = getVirtualYjsChild(root, node) + + return virtualChild ? cloneYjsNodeWithRoot(virtualChild, root) : null + } + + const attributes = { ...getAttributes(node) } + + deleteInternalAttributes(attributes) + if (node instanceof Y.XmlText) { const clone = new Y.XmlText() - setAttributes(clone, getAttributes(node)) + setAttributes(clone, attributes) clone.applyDelta(node.toDelta(), { sanitize: false }) return clone } const clone = new Y.XmlElement(node.nodeName) - const children = getYjsChildren(node).map(cloneYjsNode) + const children = getYjsChildren(node).flatMap((child) => { + if ( + !root && + child instanceof Y.XmlElement && + isVirtualYjsPlaceholder(child) + ) { + return [] + } + + const childClone = cloneYjsNodeWithRoot(child, root) - setAttributes(clone, getAttributes(node)) + return childClone ? [childClone] : [] + }) + + setAttributes(clone, attributes) if (children.length > 0) { clone.insert(0, children) @@ -265,6 +317,23 @@ export const cloneYjsNode = ( return clone } +export const cloneYjsNode = ( + node: Y.XmlElement | Y.XmlText +): Y.XmlElement | Y.XmlText => { + const clone = cloneYjsNodeWithRoot(node, null) + + if (!clone) { + throw new Error('Cannot clone a missing Yjs node.') + } + + return clone +} + +export const cloneVisibleYjsNode = ( + root: Y.XmlElement, + node: Y.XmlElement | Y.XmlText +): Y.XmlElement | Y.XmlText | null => cloneYjsNodeWithRoot(node, root) + export const getYjsNode = ( root: Y.XmlElement, path: Path @@ -429,6 +498,7 @@ export const getYjsParent = ( const deleteInternalAttributes = (attributes: Record) => { delete attributes[HIDDEN_ATTRIBUTE] delete attributes[NODE_ID_ATTRIBUTE] + delete attributes[SPLIT_UNDO_TEXT_ATTRIBUTE] delete attributes[VIRTUAL_CHILD_ID_ATTRIBUTE] delete attributes[VIRTUAL_PLACEHOLDER_ATTRIBUTE] } diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index 2d23238c5d..9da5074a2c 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -2,7 +2,7 @@ import type { Descendant, Operation } from 'slate' import * as Y from 'yjs' import { - cloneYjsNode, + cloneVisibleYjsNode, createVirtualYjsMovePlaceholder, createYjsNode, getYjsChildren, @@ -329,6 +329,77 @@ const pathsEqual = (left: readonly number[], right: readonly number[]) => left.length === right.length && left.every((part, index) => part === right[index]) +const getYjsNodeIf = (root: Y.XmlElement, path: number[]) => { + try { + return getYjsNode(root, path) + } catch { + return null + } +} + +const isEmptyYjsText = (node: Y.XmlElement | Y.XmlText) => + node instanceof Y.XmlText && getYjsTextContent(node).length === 0 + +const getYjsElementType = (element: Y.XmlElement) => + String(element.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? element.nodeName) + +type YjsElementChildKind = 'element' | 'empty' | 'mixed' | 'text' + +const getYjsElementChildKind = ( + root: Y.XmlElement, + element: Y.XmlElement +): YjsElementChildKind => { + let kind: YjsElementChildKind = 'empty' + + for (const child of getYjsVisibleChildren(root, element)) { + const childKind = child instanceof Y.XmlText ? 'text' : 'element' + + if (kind === 'empty') { + kind = childKind + continue + } + + if (kind !== childKind) { + return 'mixed' + } + } + + return kind +} + +const canMergeYjsElements = ( + root: Y.XmlElement, + previous: Y.XmlElement, + target: Y.XmlElement +) => { + if (getYjsElementType(previous) !== getYjsElementType(target)) { + return false + } + + const previousKind = getYjsElementChildKind(root, previous) + const targetKind = getYjsElementChildKind(root, target) + + if (previousKind === 'mixed' || targetKind === 'mixed') { + return false + } + + return ( + previousKind === 'empty' || + targetKind === 'empty' || + previousKind === targetKind + ) +} + +const unsupportedYjsOperation = (operation: never): never => { + const operationType = (operation as { type?: unknown }).type + + throw new Error( + `Unsupported Yjs operation: ${ + typeof operationType === 'string' ? operationType : 'unknown' + }` + ) +} + export const applySlateOperationToYjs = ( root: Y.XmlElement, operation: Operation @@ -415,7 +486,11 @@ export const applySlateOperationToYjs = ( const children = getYjsChildren(target) const rightChildren = children .slice(operation.position) - .map((child) => cloneYjsNode(child)) + .flatMap((child) => { + const clone = cloneVisibleYjsNode(root, child) + + return clone ? [clone] : [] + }) const deleteCount = getYjsLength(target) - operation.position if (deleteCount > 0) { @@ -446,6 +521,14 @@ export const applySlateOperationToYjs = ( const previous = children[index - 1] const target = children[index] + if (previous instanceof Y.XmlText && !target) { + return { + fallback: 'empty-text-merge-elided', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + if (!previous || !target) { throw new Error('Cannot merge a missing Yjs node.') } @@ -459,12 +542,27 @@ export const applySlateOperationToYjs = ( } if (previous instanceof Y.XmlElement && target instanceof Y.XmlElement) { - for (const child of getYjsChildren(target)) { + if (!canMergeYjsElements(root, previous, target)) { + return { + fallback: 'incompatible-structural-merge-elided', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + + const previousHasChildren = + getYjsVisibleChildren(root, previous).length > 0 + + for (const moveTarget of getYjsVisibleChildren(root, target)) { + if (previousHasChildren && isEmptyYjsText(moveTarget)) { + continue + } + insertYjsChild( root, previous, getYjsLength(previous), - createVirtualYjsMovePlaceholder(child) + createVirtualYjsMovePlaceholder(moveTarget) ) } @@ -572,16 +670,25 @@ export const applySlateOperationToYjs = ( return { mode: 'operation', operationType: operation.type } } case 'move_node': { - const target = getYjsNode(root, operation.path) + const target = getYjsNodeIf(root, operation.path) + + if (!target) { + return { + fallback: 'missing-move-source-elided', + mode: 'traceable-fallback', + operationType: operation.type, + } + } + const sourceParentPath = operation.path.slice(0, -1) const sourceParent = sourceParentPath.length === 0 ? root - : getYjsNode(root, sourceParentPath) + : getYjsNodeIf(root, sourceParentPath) const newParentPath = operation.newPath.slice(0, -1) const newIndex = operation.newPath.at(-1) const newParent = - newParentPath.length === 0 ? root : getYjsNode(root, newParentPath) + newParentPath.length === 0 ? root : getYjsNodeIf(root, newParentPath) if ( sourceParent instanceof Y.XmlElement && @@ -598,7 +705,11 @@ export const applySlateOperationToYjs = ( } if (!(newParent instanceof Y.XmlElement)) { - throw new Error('move_node destination parent is not a Y.XmlElement.') + return { + fallback: 'missing-move-destination-elided', + mode: 'traceable-fallback', + operationType: operation.type, + } } if (newIndex === undefined) { throw new Error('move_node destination is missing an index.') @@ -628,4 +739,6 @@ export const applySlateOperationToYjs = ( } } } + + return unsupportedYjsOperation(operation) } diff --git a/packages/slate-yjs/src/core/types.ts b/packages/slate-yjs/src/core/types.ts index b6a14874e5..55121dd28c 100644 --- a/packages/slate-yjs/src/core/types.ts +++ b/packages/slate-yjs/src/core/types.ts @@ -17,6 +17,45 @@ export type YjsAwarenessLike = { setLocalStateField: (field: string, value: unknown) => void } +export type YjsProviderStatus = + | 'connecting' + | 'connected' + | 'disconnected' + | (string & {}) + +export type YjsProviderStatusPayload = + | YjsProviderStatus + | { + status: YjsProviderStatus + } + +export type YjsProviderSyncedPayload = + | boolean + | { + state: boolean + } + | { + synced: boolean + } + +export type YjsProviderEvent = 'status' | 'sync' | 'synced' + +export type YjsProviderEventHandler = + | ((status: YjsProviderStatusPayload) => void) + | ((synced: YjsProviderSyncedPayload) => void) + +export type YjsProviderLike = { + awareness?: YjsAwarenessLike + connect?: () => Promise | unknown + destroy?: () => void + disconnect?: () => Promise | unknown + doc?: Y.Doc + off?: (event: YjsProviderEvent, handler: YjsProviderEventHandler) => void + on?: (event: YjsProviderEvent, handler: YjsProviderEventHandler) => void + status?: YjsProviderStatus + synced?: boolean +} + export type YjsAwarenessSelection = { anchor: unknown focus: unknown @@ -49,8 +88,11 @@ export type YjsExtensionOptions = { awarenessDataField?: string awarenessSelectionField?: string clientId?: number | string + destroyProviderOnUnmount?: boolean doc?: Y.Doc + provider?: YjsProviderLike rootName?: string + seedProviderOnSync?: boolean } export type YjsState = { @@ -59,6 +101,9 @@ export type YjsState = { connected: () => boolean doc: () => Y.Doc paused: () => boolean + providerRevision: () => number + providerStatus: () => YjsProviderStatus | null + providerSynced: () => boolean | null remoteCursor: < TCursorData extends Record = Record, >( @@ -69,6 +114,7 @@ export type YjsState = { >() => YjsRemoteCursor[] root: () => Y.XmlElement subscribeAwareness: (listener: () => void) => () => void + subscribeProvider: (listener: () => void) => () => void trace: () => readonly YjsTraceEntry[] } @@ -79,6 +125,7 @@ export type YjsTx = { disconnect: () => void pause: () => void reconcile: () => void + reconnect: () => void redo: () => void resume: () => void sendCursorData: (data: Record | null) => void diff --git a/packages/slate-yjs/src/react/index.ts b/packages/slate-yjs/src/react/index.ts index 2c081b18c2..28de417906 100644 --- a/packages/slate-yjs/src/react/index.ts +++ b/packages/slate-yjs/src/react/index.ts @@ -1,18 +1,230 @@ -import { useSyncExternalStore } from 'react' -import type { Editor, EditorCoreStateView } from 'slate' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import type { Editor, EditorCoreStateView, Range } from 'slate' +import { + createRangeDecorationSource, + type SlateDecorationSource, +} from 'slate-react' -import type { YjsRemoteCursor, YjsState } from '../core' +import type { YjsProviderStatus, YjsRemoteCursor, YjsState } from '../core' type YjsStateView = EditorCoreStateView & { yjs: YjsState } +type YjsDOMRangeResolver = Editor & { + api?: { + dom?: { + isFocused?: () => boolean + resolveRangeRect?: (range: Range) => DOMRect | null + } + } +} + +export type YjsRemoteCursorDecorationData< + TCursorData extends Record = Record, +> = { + clientId: number + cursor: YjsRemoteCursor + data?: TCursorData +} + +export type UseYjsRemoteCursorDecorationSourceOptions< + TCursorData extends Record = Record, + TDecorationData = YjsRemoteCursorDecorationData, +> = { + decorate?: (cursor: YjsRemoteCursor) => TDecorationData + /** Values that should recompute decoration data when decorate closes over React state. */ + deps?: readonly unknown[] + id?: string +} + +export type YjsRemoteCursorOverlayPosition< + TCursorData extends Record = Record, + TPositionData = YjsRemoteCursorDecorationData, +> = { + clientId: number + cursor: YjsRemoteCursor + data: TPositionData + range: Range + rect: DOMRect | null +} + +export type UseYjsRemoteCursorOverlayPositionsOptions< + TCursorData extends Record = Record, + TPositionData = YjsRemoteCursorDecorationData, +> = { + data?: (cursor: YjsRemoteCursor) => TPositionData + /** Values that should recompute overlay data when data closes over React state. */ + deps?: readonly unknown[] +} + +const DEFAULT_CURSOR_DECORATION_SOURCE_ID = 'yjs-remote-cursors' + +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect + const readYjsState = (editor: Editor, selector: (state: YjsState) => T) => editor.read((state) => selector((state as YjsStateView).yjs)) +const createCursorData = < + TCursorData extends Record = Record, +>( + cursor: YjsRemoteCursor +): YjsRemoteCursorDecorationData => { + const data: YjsRemoteCursorDecorationData = { + clientId: cursor.clientId, + cursor, + } + + if (cursor.data !== undefined) { + data.data = cursor.data + } + + return data +} + +const resolveCursorRect = (editor: Editor, range: Range) => { + const resolveRangeRect = (editor as YjsDOMRangeResolver).api?.dom + ?.resolveRangeRect + + if (!resolveRangeRect) { + return null + } + + try { + return resolveRangeRect(range) + } catch { + return null + } +} + +const isEditorFocused = (editor: Editor) => + Boolean((editor as YjsDOMRangeResolver).api?.dom?.isFocused?.()) + +const pointsEqual = (a: Range['anchor'], b: Range['anchor']) => + a.offset === b.offset && + a.path.length === b.path.length && + a.path.every((part, index) => part === b.path[index]) + +const rangesEqual = (a: Range, b: Range) => + pointsEqual(a.anchor, b.anchor) && pointsEqual(a.focus, b.focus) + +const rectsEqual = (a: DOMRect | null, b: DOMRect | null) => { + if (a === b) { + return true + } + if (!a || !b) { + return false + } + + return ( + a.bottom === b.bottom && + a.height === b.height && + a.left === b.left && + a.right === b.right && + a.top === b.top && + a.width === b.width && + a.x === b.x && + a.y === b.y + ) +} + +const shallowEqual = (a: unknown, b: unknown) => { + if (Object.is(a, b)) { + return true + } + if ( + typeof a !== 'object' || + a === null || + typeof b !== 'object' || + b === null + ) { + return false + } + + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + + return ( + aKeys.length === Object.keys(bRecord).length && + aKeys.every((key) => Object.is(aRecord[key], bRecord[key])) + ) +} + +const overlayPositionsEqual = < + TCursorData extends Record, + TPositionData, +>( + a: readonly YjsRemoteCursorOverlayPosition[], + b: readonly YjsRemoteCursorOverlayPosition[] +) => + a.length === b.length && + a.every((position, index) => { + const next = b[index] + + return ( + !!next && + position.clientId === next.clientId && + rangesEqual(position.range, next.range) && + rectsEqual(position.rect, next.rect) && + shallowEqual(position.data, next.data) + ) + }) + +const readYjsRemoteCursorOverlayPositions = < + TCursorData extends Record = Record, + TPositionData = YjsRemoteCursorDecorationData, +>( + editor: Editor, + options: UseYjsRemoteCursorOverlayPositionsOptions +): YjsRemoteCursorOverlayPosition[] => + readYjsState(editor, (state) => + state.remoteCursors().flatMap((cursor) => { + const range = cursor.selection + + if (!range) { + return [] + } + + const data = options.data + ? options.data(cursor) + : (createCursorData(cursor) as TPositionData) + + return [ + { + clientId: cursor.clientId, + cursor, + data, + range, + rect: resolveCursorRect(editor, range), + }, + ] + }) + ) + export const getYjsAwarenessRevision = (editor: Editor) => readYjsState(editor, (state) => state.awarenessRevision()) +export const getYjsProviderRevision = (editor: Editor) => + readYjsState(editor, (state) => state.providerRevision()) + +export const getYjsProviderStatus = ( + editor: Editor +): YjsProviderStatus | null => + readYjsState(editor, (state) => state.providerStatus()) + +export const getYjsProviderSynced = (editor: Editor): boolean | null => + readYjsState(editor, (state) => state.providerSynced()) + export function useYjsAwarenessRevision(editor: Editor) { return useSyncExternalStore( (listener) => @@ -22,6 +234,27 @@ export function useYjsAwarenessRevision(editor: Editor) { ) } +export function useYjsProviderRevision(editor: Editor) { + return useSyncExternalStore( + (listener) => + readYjsState(editor, (state) => state.subscribeProvider(listener)), + () => getYjsProviderRevision(editor), + () => getYjsProviderRevision(editor) + ) +} + +export function useYjsProviderStatus(editor: Editor): YjsProviderStatus | null { + useYjsProviderRevision(editor) + + return getYjsProviderStatus(editor) +} + +export function useYjsProviderSynced(editor: Editor): boolean | null { + useYjsProviderRevision(editor) + + return getYjsProviderSynced(editor) +} + export function useYjsRemoteCursor< TCursorData extends Record = Record, >(editor: Editor, clientId: number): YjsRemoteCursor | null { @@ -39,3 +272,152 @@ export function useYjsRemoteCursors< return readYjsState(editor, (state) => state.remoteCursors()) } + +export function useYjsRemoteCursorDecorationSource< + TCursorData extends Record = Record, + TDecorationData = YjsRemoteCursorDecorationData, +>( + editor: Editor, + options: UseYjsRemoteCursorDecorationSourceOptions< + TCursorData, + TDecorationData + > = {} +): SlateDecorationSource { + const awarenessRevision = useYjsAwarenessRevision(editor) + const decorateRefreshDeps = options.deps ?? [] + const optionsRef = useRef(options) + const id = options.id ?? DEFAULT_CURSOR_DECORATION_SOURCE_ID + optionsRef.current = options + + const source = useMemo( + () => + createRangeDecorationSource(editor, { + id, + read: () => + readYjsState(editor, (state) => + state.remoteCursors().flatMap((cursor) => { + const range = cursor.selection + + if (!range) { + return [] + } + + const decorate = optionsRef.current.decorate + const data = decorate + ? decorate(cursor) + : (createCursorData(cursor) as TDecorationData) + + return [ + { + data, + key: `${id}:${cursor.clientId}`, + range, + }, + ] + }) + ), + }), + [editor, id] + ) + + useEffect(() => () => source.destroy(), [source]) + + useEffect(() => { + source.refresh({ + forceInvalidate: true, + reason: 'external', + requiresDOMSelectionExport: isEditorFocused(editor), + }) + }, [awarenessRevision, source, ...decorateRefreshDeps]) + + return source +} + +export function useYjsRemoteCursorOverlayPositions< + TCursorData extends Record = Record, + TPositionData = YjsRemoteCursorDecorationData, +>( + editor: Editor, + options: UseYjsRemoteCursorOverlayPositionsOptions< + TCursorData, + TPositionData + > = {} +): readonly [ + readonly YjsRemoteCursorOverlayPosition[], + () => void, +] { + const awarenessRevision = useYjsAwarenessRevision(editor) + const dataRefreshDeps = options.deps ?? [] + const animationFrameRef = useRef(null) + const optionsRef = useRef(options) + optionsRef.current = options + + const readPositions = useCallback( + () => + readYjsRemoteCursorOverlayPositions( + editor, + optionsRef.current + ), + [editor] + ) + const [positions, setPositions] = useState(readPositions) + const refresh = useCallback(() => { + const next = readPositions() + + setPositions((current) => + overlayPositionsEqual(current, next) ? current : next + ) + }, [readPositions]) + const cancelScheduledRefresh = useCallback(() => { + if (typeof window === 'undefined' || animationFrameRef.current === null) { + return + } + + window.cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + }, []) + const refreshAfterEditorLayout = useCallback(() => { + refresh() + + if (typeof window === 'undefined' || !window.requestAnimationFrame) { + return + } + + cancelScheduledRefresh() + animationFrameRef.current = window.requestAnimationFrame(() => { + animationFrameRef.current = null + refresh() + }) + }, [cancelScheduledRefresh, refresh]) + + useIsomorphicLayoutEffect(() => { + refresh() + }, [awarenessRevision, refresh, ...dataRefreshDeps]) + + useIsomorphicLayoutEffect(() => { + const unsubscribe = editor.subscribe(() => { + refreshAfterEditorLayout() + }) + + return () => { + unsubscribe() + cancelScheduledRefresh() + } + }, [cancelScheduledRefresh, editor, refreshAfterEditorLayout]) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + window.addEventListener('resize', refresh) + window.addEventListener('scroll', refresh, true) + + return () => { + window.removeEventListener('resize', refresh) + window.removeEventListener('scroll', refresh, true) + } + }, [refresh]) + + return [positions, refresh] as const +} diff --git a/packages/slate-yjs/test/merge-node-contract.spec.ts b/packages/slate-yjs/test/merge-node-contract.spec.ts index 5743c707c3..862cc72f81 100644 --- a/packages/slate-yjs/test/merge-node-contract.spec.ts +++ b/packages/slate-yjs/test/merge-node-contract.spec.ts @@ -5,6 +5,7 @@ import { Editor } from 'slate/internal' import * as Y from 'yjs' import { createYjsExtension } from '../src' +import { readSlateValueFromYjs } from '../src/core/document' type Peer = { doc: Y.Doc @@ -16,8 +17,18 @@ const paragraph = (text: string): Descendant => ({ children: [{ text }], }) +const quote = (...children: Descendant[]): Descendant => ({ + type: 'block-quote', + children, +}) + const initialValue = () => [paragraph('alpha'), paragraph('beta')] +const incompatibleMergeValue = (): Descendant[] => [ + paragraph('block 2'), + quote(paragraph('alpha'), paragraph('beta')), +] + const textMergeValue = (): Descendant[] => [ { type: 'paragraph', @@ -167,6 +178,33 @@ const appendRemoteTextToLeftParagraph = (peer: Peer) => { } describe('@slate/yjs merge_node collaboration contract', () => { + it('elides incompatible structural merge instead of nesting blocks into a paragraph', () => { + const peer = createPeer('b', undefined, incompatibleMergeValue()) + + yjsUpdate(peer, (yjs) => yjs.clearTrace()) + mergeSecondParagraph(peer) + + assert.deepEqual(readSlateValueFromYjs(yjsState(peer).root()), [ + paragraph('block 2'), + quote(paragraph('alpha'), paragraph('beta')), + ]) + assert.deepEqual(yjsState(peer).trace(), [ + { + fallback: 'incompatible-structural-merge-elided', + mode: 'traceable-fallback', + operationType: 'merge_node', + }, + ]) + + yjsUpdate(peer, (yjs) => yjs.reconcile()) + + assert.deepEqual( + Editor.getSnapshot(peer.editor).children, + incompatibleMergeValue() + ) + assertNoRootSnapshot(peer) + }) + it('applies local offline public merge without a root snapshot fallback', () => { const peer = createPeer('b') const survivor = yjsNodeAt(peer, [0]) diff --git a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts new file mode 100644 index 0000000000..a4195c4650 --- /dev/null +++ b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { Operation } from 'slate' +import * as Y from 'yjs' + +import { applySlateOperationToYjs } from '../src/core/operations' + +describe('@slate/yjs operation encoder exhaustiveness contract', () => { + it('treats selection operations as document-content no-ops', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const operation: Operation = { + newProperties: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }, + properties: null, + type: 'set_selection', + } + + assert.equal(applySlateOperationToYjs(root, operation), null) + }) + + it('rejects a future Slate operation instead of silently skipping it', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const operation = { + type: 'future_operation', + } as unknown as Operation + + assert.throws( + () => applySlateOperationToYjs(root, operation), + /Unsupported Yjs operation: future_operation/ + ) + }) +}) diff --git a/packages/slate-yjs/test/package-config-contract.spec.ts b/packages/slate-yjs/test/package-config-contract.spec.ts index cf053c3a84..f594e58f83 100644 --- a/packages/slate-yjs/test/package-config-contract.spec.ts +++ b/packages/slate-yjs/test/package-config-contract.spec.ts @@ -24,4 +24,19 @@ describe('@slate/yjs package config contract', () => { assert.equal(yjsAlias, undefined) }) + + it('keeps provider integrations supplied by applications', () => { + const yjsPackage = readJson('../package.json') + const sections = [ + yjsPackage.dependencies, + yjsPackage.devDependencies, + yjsPackage.peerDependencies, + yjsPackage.optionalDependencies, + ] + + for (const dependencies of sections) { + assert.equal(dependencies?.['@hocuspocus/provider'], undefined) + assert.equal(dependencies?.['y-websocket'], undefined) + } + }) }) diff --git a/packages/slate-yjs/test/provider-contract.spec.ts b/packages/slate-yjs/test/provider-contract.spec.ts new file mode 100644 index 0000000000..176933dfca --- /dev/null +++ b/packages/slate-yjs/test/provider-contract.spec.ts @@ -0,0 +1,870 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Descendant, type Range } from 'slate' +import { Editor } from 'slate/internal' +import { history } from 'slate-history' +import * as Y from 'yjs' + +import { createYjsExtension } from '../src' +import type { + YjsExtensionOptions, + YjsProviderEvent, + YjsProviderEventHandler, + YjsProviderLike, + YjsProviderStatus, + YjsProviderStatusPayload, + YjsProviderSyncedPayload, +} from '../src/core/types' +import { + createYjsPeer, + FakeAwareness, + getYjsState, + runYjsUpdate, +} from './support/collaboration' + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [paragraph('alpha'), paragraph('beta')] + +const selection = (): Range => ({ + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, +}) + +class FakeProvider implements YjsProviderLike { + readonly awareness = new FakeAwareness(12) + readonly doc = new Y.Doc() + + readonly calls: string[] = [] + + status: YjsProviderStatus = 'disconnected' + synced = false + + private readonly statusListeners = new Set< + (status: YjsProviderStatusPayload) => void + >() + private readonly syncedListeners = new Set< + (synced: YjsProviderSyncedPayload) => void + >() + private readonly syncListeners = new Set< + (synced: YjsProviderSyncedPayload) => void + >() + + connect() { + this.calls.push('connect') + this.emitStatus('connected') + } + + destroy() { + this.calls.push('destroy') + } + + disconnect() { + this.calls.push('disconnect') + this.emitStatus('disconnected') + } + + emitStatus(status: YjsProviderStatusPayload) { + this.status = typeof status === 'string' ? status : status.status + + for (const listener of this.statusListeners) { + listener(status) + } + } + + emitSynced(synced: boolean) { + this.synced = synced + + for (const listener of this.syncedListeners) { + listener(synced) + } + } + + emitSyncedState(synced: boolean) { + this.synced = synced + + for (const listener of this.syncedListeners) { + listener({ state: synced }) + } + } + + emitSync(synced: boolean) { + this.synced = synced + + for (const listener of this.syncListeners) { + listener(synced) + } + } + + off(event: YjsProviderEvent, handler: YjsProviderEventHandler) { + if (event === 'status') { + this.statusListeners.delete( + handler as (status: YjsProviderStatusPayload) => void + ) + } else if (event === 'sync') { + this.syncListeners.delete( + handler as (synced: YjsProviderSyncedPayload) => void + ) + } else { + this.syncedListeners.delete( + handler as (synced: YjsProviderSyncedPayload) => void + ) + } + } + + on(event: YjsProviderEvent, handler: YjsProviderEventHandler) { + if (event === 'status') { + this.statusListeners.add( + handler as (status: YjsProviderStatusPayload) => void + ) + } else if (event === 'sync') { + this.syncListeners.add( + handler as (synced: YjsProviderSyncedPayload) => void + ) + } else { + this.syncedListeners.add( + handler as (synced: YjsProviderSyncedPayload) => void + ) + } + } +} + +class DeferredConnectProvider extends FakeProvider { + override connect() { + this.calls.push('connect') + } +} + +class AsyncDisconnectProvider extends FakeProvider { + resolveDisconnect: (() => void) | null = null + + override disconnect() { + this.calls.push('disconnect') + + return new Promise((resolve) => { + this.resolveDisconnect = () => { + this.emitStatus('disconnected') + resolve() + } + }) + } +} + +class StatusOnlyProvider extends FakeProvider { + override connect() { + this.calls.push('connect') + this.status = 'connected' + } + + override disconnect() { + this.calls.push('disconnect') + this.status = 'disconnected' + } +} + +class FireAndForgetDisconnectProvider extends FakeProvider { + override disconnect() { + this.calls.push('disconnect') + } +} + +const createYjsUpdate = (children: Descendant[]) => { + const doc = new Y.Doc() + + createEditor({ + extensions: [ + createYjsExtension({ + clientId: 'seed', + doc, + rootName: 'slate', + }), + ], + initialValue: children, + }) + + return Y.encodeStateAsUpdate(doc) +} + +const seedProviderDoc = ( + provider: FakeProvider, + children: Descendant[] = initialValue() +) => { + Y.applyUpdate(provider.doc, createYjsUpdate(children)) + provider.synced = true +} + +const createProviderEditor = ( + provider: FakeProvider, + options: Partial = {} +) => { + const editor = createEditor() + + Editor.replace(editor, { + children: initialValue(), + marks: null, + selection: null, + }) + + const cleanup = editor.extend( + createYjsExtension({ + clientId: 'provider-peer', + provider, + rootName: 'slate', + ...options, + }) + ) + + return { cleanup, editor } +} + +const createProviderEditorWithHistory = ( + provider: FakeProvider, + order: 'history-first' | 'yjs-first' +) => { + const editor = createEditor() + const cleanups: (() => void)[] = [] + + Editor.replace(editor, { + children: initialValue(), + marks: null, + selection: null, + }) + + if (order === 'history-first') { + cleanups.push(editor.extend(history())) + } + + cleanups.push( + editor.extend( + createYjsExtension({ + clientId: `provider-peer-${order}`, + provider, + rootName: 'slate', + }) + ) + ) + + if (order === 'yjs-first') { + cleanups.push(editor.extend(history())) + } + + return { + cleanup: () => { + for (const cleanup of [...cleanups].reverse()) { + cleanup() + } + }, + editor, + } +} + +describe('@slate/yjs provider contract', () => { + it('returns nullable provider state without a provider', () => { + const peer = createYjsPeer({ + children: initialValue(), + clientId: 'a', + }) + + assert.equal(getYjsState(peer).providerStatus(), null) + assert.equal(getYjsState(peer).providerSynced(), null) + + runYjsUpdate(peer, (yjs) => { + yjs.disconnect() + assert.equal(getYjsState(peer).connected(), false) + yjs.reconnect() + }) + + assert.equal(getYjsState(peer).connected(), true) + }) + + it('uses provider doc and awareness as additive defaults', () => { + const provider = new FakeProvider() + seedProviderDoc(provider) + const { cleanup, editor } = createProviderEditor(provider) + const yjs = editor.read((state) => (state as any).yjs) + + assert.equal(yjs.doc(), provider.doc) + assert.equal(yjs.providerStatus(), 'disconnected') + assert.equal(yjs.providerSynced(), true) + assert.equal(yjs.connected(), false) + + editor.update((tx) => { + ;(tx as any).yjs.sendSelection(selection(), { name: 'Provider peer' }) + }) + + assert.deepEqual(provider.awareness.getLocalState()?.data, { + name: 'Provider peer', + }) + + cleanup() + }) + + it('subscribes to provider status and provider-reported sync changes', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const yjs = editor.read((state) => (state as any).yjs) + const seen: [YjsProviderStatus | null, boolean | null][] = [] + const unsubscribe = yjs.subscribeProvider(() => { + seen.push([yjs.providerStatus(), yjs.providerSynced()]) + }) + + provider.emitStatus('connecting') + provider.emitSync(true) + provider.emitStatus({ status: 'connected' }) + provider.emitSynced(false) + provider.emitSyncedState(true) + unsubscribe() + provider.emitStatus('disconnected') + + assert.deepEqual(seen, [ + ['connecting', false], + ['connecting', true], + ['connected', true], + ['connected', false], + ['connected', true], + ]) + + cleanup() + }) + + it('does not seed a provider-owned document before provider sync', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + + Y.applyUpdate(provider.doc, createYjsUpdate([paragraph('remote')])) + + assert.equal(Editor.string(editor, [0]), 'remote') + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'remote') + assert.equal(root.length, 1) + + cleanup() + }) + + it('does not reconcile an unsafe empty provider doc before sync', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + editor.update((tx) => { + ;(tx as any).yjs.reconcile() + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + cleanup() + }) + + it('does not save rejected pre-sync provider edits in Slate history', async () => { + for (const order of ['history-first', 'yjs-first'] as const) { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditorWithHistory( + provider, + order + ) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + await Promise.resolve() + + assert.equal(Editor.string(editor, [0]), 'alpha', order) + assert.equal( + editor.read((state) => (state as any).history.undos().length), + 0, + order + ) + + editor.update((tx) => { + ;(tx as any).history.undo() + }) + + assert.equal(Editor.string(editor, [0]), 'alpha', order) + + cleanup() + } + }) + + it('exports local edits after remote content arrives before provider sync', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + Y.applyUpdate(provider.doc, createYjsUpdate([paragraph('remote')])) + + assert.equal(Editor.string(editor, [0]), 'remote') + assert.equal(root.length, 1) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'remote'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'remote!') + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'remote!') + assert.equal(root.length, 1) + + cleanup() + }) + + it('seeds empty synced provider docs by default', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 2) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha!') + assert.equal(root.length, 2) + + cleanup() + }) + + it('allows apps to opt out of seeding empty synced provider docs', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider, { + seedProviderOnSync: false, + }) + const root = provider.doc.get('slate', Y.XmlElement) + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + cleanup() + }) + + it('rejects local edits before an empty provider doc syncs', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + assert.doesNotThrow(() => { + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + }) + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 2) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha!') + assert.equal(root.length, 2) + + cleanup() + }) + + it('keeps provider content authoritative after rejecting pre-sync edits', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + Y.applyUpdate(provider.doc, createYjsUpdate([paragraph('remote')])) + + assert.equal(Editor.string(editor, [0]), 'remote') + assert.equal(root.length, 1) + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'remote') + assert.equal(root.length, 1) + + cleanup() + }) + + it('does not seed provider docs with unknown sync state by default', () => { + const provider = new FakeProvider() + delete (provider as Partial).synced + const { cleanup, editor } = createProviderEditor(provider) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + cleanup() + }) + + it('does not seed provider docs with unknown sync state when explicitly requested', () => { + const provider = new FakeProvider() + delete (provider as Partial).synced + const { cleanup, editor } = createProviderEditor(provider, { + seedProviderOnSync: true, + }) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 2) + + cleanup() + }) + + it('treats an explicit provider doc as sync-gated provider state', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider, { + doc: provider.doc, + seedProviderOnSync: true, + }) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 2) + + cleanup() + }) + + it('sync-gates explicit docs even when providers do not expose a doc property', () => { + const provider = new FakeProvider() + const doc = provider.doc + delete (provider as Partial).doc + const { cleanup, editor } = createProviderEditor(provider, { + doc, + seedProviderOnSync: true, + }) + const root = doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 0) + + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 2) + + cleanup() + }) + + it('seeds empty provider docs on sync when explicitly requested', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider, { + seedProviderOnSync: true, + }) + const root = provider.doc.get('slate', Y.XmlElement) + + assert.equal(root.length, 0) + provider.emitSync(true) + + assert.equal(Editor.string(editor, [0]), 'alpha') + assert.equal(root.length, 2) + + editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.equal(Editor.string(editor, [0]), 'alpha!') + + editor.update((tx) => { + ;(tx as any).yjs.undo() + }) + + assert.equal(Editor.string(editor, [0]), 'alpha') + + cleanup() + }) + + it('uses provider status events as the remote cursor visibility gate', () => { + const provider = new FakeProvider() + provider.status = 'connected' + seedProviderDoc(provider) + const { cleanup, editor } = createProviderEditor(provider) + const yjs = editor.read((state) => (state as any).yjs) + + editor.update((tx) => { + ;(tx as any).yjs.sendSelection(selection(), { name: 'Remote peer' }) + }) + provider.awareness.setRemoteState(88, { + data: { name: 'Remote peer' }, + selection: provider.awareness.getLocalState()?.selection, + }) + + assert.equal(yjs.connected(), true) + assert.equal(yjs.remoteCursors().length, 1) + + provider.emitStatus({ status: 'disconnected' }) + + assert.equal(yjs.connected(), false) + assert.deepEqual(yjs.remoteCursors(), []) + + provider.emitStatus('connected') + + assert.equal(yjs.connected(), true) + assert.equal(yjs.remoteCursors().length, 1) + + cleanup() + }) + + it('does not expose stale cursors while provider connect is pending', () => { + const provider = new DeferredConnectProvider() + seedProviderDoc(provider) + const { cleanup, editor } = createProviderEditor(provider) + const yjs = editor.read((state) => (state as any).yjs) + + editor.update((tx) => { + ;(tx as any).yjs.sendSelection(selection(), { name: 'Remote peer' }) + }) + provider.awareness.setRemoteState(88, { + data: { name: 'Remote peer' }, + selection: provider.awareness.getLocalState()?.selection, + }) + + assert.equal(yjs.connected(), false) + assert.deepEqual(yjs.remoteCursors(), []) + + editor.update((tx) => { + ;(tx as any).yjs.connect() + }) + + assert.deepEqual(provider.calls, ['connect']) + assert.equal(yjs.providerStatus(), 'disconnected') + assert.equal(yjs.connected(), false) + assert.deepEqual(yjs.remoteCursors(), []) + + provider.emitStatus('connected') + + assert.equal(yjs.connected(), true) + assert.equal(yjs.remoteCursors().length, 1) + + cleanup() + }) + + it('reads imperative provider status after lifecycle calls without events', () => { + const provider = new StatusOnlyProvider() + seedProviderDoc(provider) + const { cleanup, editor } = createProviderEditor(provider) + const yjs = editor.read((state) => (state as any).yjs) + + assert.equal(yjs.providerStatus(), 'disconnected') + assert.equal(yjs.connected(), false) + + editor.update((tx) => { + ;(tx as any).yjs.connect() + }) + + assert.deepEqual(provider.calls, ['connect']) + assert.equal(yjs.providerStatus(), 'connected') + assert.equal(yjs.connected(), true) + + editor.update((tx) => { + ;(tx as any).yjs.disconnect() + }) + + assert.deepEqual(provider.calls, ['connect', 'disconnect']) + assert.equal(yjs.providerStatus(), 'disconnected') + assert.equal(yjs.connected(), false) + + cleanup() + }) + + it('keeps local disconnect authoritative while provider status is stale', () => { + const provider = new FireAndForgetDisconnectProvider() + provider.status = 'connected' + seedProviderDoc(provider) + const { cleanup, editor } = createProviderEditor(provider) + const yjs = editor.read((state) => (state as any).yjs) + + assert.equal(yjs.connected(), true) + + editor.update((tx) => { + ;(tx as any).yjs.disconnect() + }) + + assert.deepEqual(provider.calls, ['disconnect']) + assert.equal(yjs.providerStatus(), 'connected') + assert.equal(yjs.connected(), false) + + cleanup() + }) + + it('delegates reconnect to optional provider transport methods in order', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + + editor.update((tx) => { + ;(tx as any).yjs.reconnect() + }) + + assert.deepEqual(provider.calls, ['disconnect', 'connect']) + assert.equal( + editor.read((state) => (state as any).yjs.connected()), + true + ) + assert.equal( + editor.read((state) => (state as any).yjs.providerStatus()), + 'connected' + ) + + cleanup() + }) + + it('waits for async provider disconnect before reconnecting', async () => { + const provider = new AsyncDisconnectProvider() + const { cleanup, editor } = createProviderEditor(provider) + + editor.update((tx) => { + ;(tx as any).yjs.reconnect() + }) + + assert.deepEqual(provider.calls, ['disconnect']) + + provider.resolveDisconnect?.() + await Promise.resolve() + + assert.deepEqual(provider.calls, ['disconnect', 'connect']) + + cleanup() + }) + + it('keeps pause separate from provider disconnect', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + + editor.update((tx) => { + ;(tx as any).yjs.pause() + ;(tx as any).yjs.disconnect() + }) + + const yjs = editor.read((state) => (state as any).yjs) + + assert.equal(yjs.paused(), true) + assert.equal(yjs.connected(), false) + assert.deepEqual(provider.calls, ['disconnect']) + + cleanup() + }) + + it('cleans up provider listeners and local awareness selection without destroying app-owned providers', () => { + const provider = new FakeProvider() + seedProviderDoc(provider) + const { cleanup, editor } = createProviderEditor(provider) + let notifications = 0 + const unsubscribe = editor + .read((state) => (state as any).yjs) + .subscribeProvider(() => { + notifications += 1 + }) + + editor.update((tx) => { + ;(tx as any).yjs.sendSelection(selection(), { name: 'Provider peer' }) + }) + + cleanup() + unsubscribe() + provider.emitStatus('connected') + provider.emitSynced(true) + + assert.equal(notifications, 0) + assert.deepEqual(provider.calls, []) + assert.equal(provider.awareness.getLocalState()?.selection, null) + assert.deepEqual(provider.awareness.getLocalState()?.data, { + name: 'Provider peer', + }) + }) + + it('does not create provider awareness state during cleanup', () => { + const provider = new FakeProvider() + seedProviderDoc(provider) + const { cleanup } = createProviderEditor(provider) + + assert.equal(provider.awareness.getLocalState(), null) + assert.equal( + provider.awareness.getStates().has(provider.awareness.clientID), + false + ) + + cleanup() + + assert.equal(provider.awareness.getLocalState(), null) + assert.equal( + provider.awareness.getStates().has(provider.awareness.clientID), + false + ) + }) + + it('destroys providers only when explicitly owned by the editor', () => { + const provider = new FakeProvider() + provider.synced = true + const { cleanup } = createProviderEditor(provider, { + destroyProviderOnUnmount: true, + }) + + cleanup() + + assert.deepEqual(provider.calls, ['destroy']) + }) +}) diff --git a/packages/slate-yjs/test/react-contract.spec.tsx b/packages/slate-yjs/test/react-contract.spec.tsx new file mode 100644 index 0000000000..9e82c59074 --- /dev/null +++ b/packages/slate-yjs/test/react-contract.spec.tsx @@ -0,0 +1,416 @@ +import assert from 'node:assert/strict' +import { after, describe, it } from 'node:test' +import { GlobalRegistrator } from '@happy-dom/global-registrator' +import React, { act, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import type { Descendant, Editor, Range } from 'slate' +import type * as Y from 'yjs' + +import type { + YjsProviderEvent, + YjsProviderEventHandler, + YjsProviderLike, + YjsProviderStatus, + YjsRemoteCursorDecorationData, +} from '../src' +import { + useYjsProviderStatus, + useYjsProviderSynced, + useYjsRemoteCursorDecorationSource, + useYjsRemoteCursorOverlayPositions, +} from '../src/react' +import { + createYjsPeer, + FakeAwareness, + type Peer, + runYjsUpdate, +} from './support/collaboration' + +const shouldUnregisterHappyDOM = !GlobalRegistrator.isRegistered + +if (shouldUnregisterHappyDOM) { + GlobalRegistrator.register() +} +;( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true + +after(() => { + if (shouldUnregisterHappyDOM) { + GlobalRegistrator.unregister() + } +}) + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const initialValue = () => [ + paragraph('alpha'), + paragraph('beta'), + paragraph('gamma'), +] + +const selection = (path = [0, 0], offset = 1): Range => ({ + anchor: { path, offset }, + focus: { path, offset: offset + 2 }, +}) + +const render = (element: React.ReactNode) => { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + + act(() => { + root.render(element) + }) + + return { + container, + unmount() { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +const sendRemoteSelection = ( + peer: Peer, + awareness: FakeAwareness, + range: Range, + clientId = 101 +) => { + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range) + awareness.setRemoteState(clientId, { + data: { color: 'tomato', name: 'Ada' }, + selection: awareness.getLocalState()?.selection, + }) + }) +} + +class FakeProvider implements YjsProviderLike { + awareness = new FakeAwareness(7) + doc?: Y.Doc + status: YjsProviderStatus = 'connecting' + synced = false + + private readonly statusListeners = new Set< + (status: YjsProviderStatus) => void + >() + private readonly syncedListeners = new Set<(synced: boolean) => void>() + + emitStatus(status: YjsProviderStatus) { + this.status = status + for (const listener of this.statusListeners) { + listener(status) + } + } + + emitSynced(synced: boolean) { + this.synced = synced + for (const listener of this.syncedListeners) { + listener(synced) + } + } + + off(event: YjsProviderEvent, handler: YjsProviderEventHandler) { + if (event === 'status') { + this.statusListeners.delete( + handler as (status: YjsProviderStatus) => void + ) + } else { + this.syncedListeners.delete(handler as (synced: boolean) => void) + } + } + + on(event: YjsProviderEvent, handler: YjsProviderEventHandler) { + if (event === 'status') { + this.statusListeners.add(handler as (status: YjsProviderStatus) => void) + } else { + this.syncedListeners.add(handler as (synced: boolean) => void) + } + } +} + +describe('@slate/yjs react contract', () => { + it('rerenders provider status hooks from provider lifecycle events', () => { + const provider = new FakeProvider() + const peer = createYjsPeer({ + children: initialValue(), + clientId: 'a', + provider, + }) + + const ProviderProbe = ({ editor }: { editor: Editor }) => { + const status = useYjsProviderStatus(editor) + const synced = useYjsProviderSynced(editor) + + return ( + + {status ?? 'none'}:{String(synced)} + + ) + } + + const view = render() + + assert.equal(view.container.textContent, 'connecting:false') + + act(() => { + provider.emitStatus('connected') + }) + assert.equal(view.container.textContent, 'connected:false') + + act(() => { + provider.emitSynced(true) + }) + assert.equal(view.container.textContent, 'connected:true') + + view.unmount() + peer.cleanup() + }) + + it('exposes remote cursors as a DOM-neutral decoration source', () => { + const awareness = new FakeAwareness(2) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'b', + numericClientId: 2, + }) + ;(peer.editor as any).api = { + ...(peer.editor as any).api, + dom: { + isFocused: () => true, + }, + } + let source: ReturnType | null = + null + let lastRefreshRequiresDOMSelectionExport: boolean | null = null + + const DecorationProbe = ({ editor }: { editor: Editor }) => { + const cursorSource = useYjsRemoteCursorDecorationSource(editor) + + useEffect(() => { + source = cursorSource + + return cursorSource.subscribeProjectionRefresh((result) => { + lastRefreshRequiresDOMSelectionExport = + result.requiresDOMSelectionExport + }) + }, [cursorSource]) + + return null + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([0, 0], 1)) + }) + + assert.ok(source) + const slices = Object.values(source.getSnapshot()).flat() + + assert.equal(lastRefreshRequiresDOMSelectionExport, true) + assert.equal(slices.length, 1) + assert.equal( + ( + slices[0]?.data as + | YjsRemoteCursorDecorationData<{ color: string; name: string }> + | undefined + )?.clientId, + 101 + ) + assert.deepEqual( + ( + slices[0]?.data as + | YjsRemoteCursorDecorationData<{ color: string; name: string }> + | undefined + )?.data, + { color: 'tomato', name: 'Ada' } + ) + + view.unmount() + peer.cleanup() + }) + + it('refreshes remote cursor decorations when decoration deps change', () => { + const awareness = new FakeAwareness(4) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'd', + numericClientId: 4, + }) + let source: ReturnType< + typeof useYjsRemoteCursorDecorationSource< + { color: string; name: string }, + { clientId: number; label: string } + > + > | null = null + let setLabel: ((label: string) => void) | null = null + + const DecorationProbe = ({ editor }: { editor: Editor }) => { + const [label, updateLabel] = React.useState('Ada') + const cursorSource = useYjsRemoteCursorDecorationSource< + { color: string; name: string }, + { clientId: number; label: string } + >(editor, { + decorate: (cursor) => ({ clientId: cursor.clientId, label }), + deps: [label], + }) + + useEffect(() => { + setLabel = updateLabel + source = cursorSource + }, [cursorSource, updateLabel]) + + return null + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([0, 0], 1)) + }) + + assert.ok(source) + assert.equal( + Object.values(source.getSnapshot()).flat()[0]?.data.label, + 'Ada' + ) + + act(() => { + setLabel?.('Grace') + }) + + assert.equal( + Object.values(source.getSnapshot()).flat()[0]?.data.label, + 'Grace' + ) + + view.unmount() + peer.cleanup() + }) + + it('resolves remote cursor overlay rectangles through the editor DOM API', () => { + const awareness = new FakeAwareness(3) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'c', + numericClientId: 3, + }) + let rect = { + bottom: 40, + height: 20, + left: 10, + right: 30, + top: 20, + width: 20, + x: 10, + y: 20, + } as DOMRect + + ;(peer.editor as any).api = { + ...(peer.editor as any).api, + dom: { + resolveRangeRect: () => rect, + }, + } + + const OverlayProbe = ({ editor }: { editor: Editor }) => { + const [positions] = useYjsRemoteCursorOverlayPositions(editor) + + return ( + + {positions.map( + (position) => `${position.clientId}:${position.rect?.x}` + )} + + ) + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + }) + + assert.equal(view.container.textContent, '101:10') + + act(() => { + rect = { + ...rect, + left: 25, + right: 45, + x: 25, + } + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) + }) + }) + + assert.equal(view.container.textContent, '101:25') + + view.unmount() + peer.cleanup() + }) + + it('refreshes remote cursor overlay data when overlay deps change', () => { + const awareness = new FakeAwareness(5) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'e', + numericClientId: 5, + }) + let setLabel: ((label: string) => void) | null = null + + ;(peer.editor as any).api = { + ...(peer.editor as any).api, + dom: { + resolveRangeRect: () => null, + }, + } + + const OverlayProbe = ({ editor }: { editor: Editor }) => { + const [label, updateLabel] = React.useState('Ada') + const [positions] = useYjsRemoteCursorOverlayPositions< + { color: string; name: string }, + { label: string } + >(editor, { + data: () => ({ label }), + deps: [label], + }) + + useEffect(() => { + setLabel = updateLabel + }, [updateLabel]) + + return {positions[0]?.data.label} + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + }) + + assert.equal(view.container.textContent, 'Ada') + + act(() => { + setLabel?.('Grace') + }) + + assert.equal(view.container.textContent, 'Grace') + + view.unmount() + peer.cleanup() + }) +}) diff --git a/packages/slate-yjs/test/split-merge-contract.spec.ts b/packages/slate-yjs/test/split-merge-contract.spec.ts new file mode 100644 index 0000000000..54881fdc99 --- /dev/null +++ b/packages/slate-yjs/test/split-merge-contract.spec.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { type Descendant } from 'slate' +import { Editor } from 'slate/internal' + +import { readSlateValueFromYjs } from '../src/core/document' +import { + assertNoRootSnapshot, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsState, + type Peer, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +const clientIds = { + a: 1, + b: 2, + c: 3, +} as const + +const paragraph = (text: string): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const paragraphParts = (...texts: string[]): Descendant => ({ + type: 'paragraph', + children: texts.map((text) => ({ text })), +}) + +const quote = (children: Descendant[]): Descendant => ({ + type: 'quote', + children, +}) + +const initialValue = () => [paragraph('Hello world!')] + +const createPeer = ( + clientId: keyof typeof clientIds, + children = initialValue() +) => + createYjsPeer({ + children, + clientId, + numericClientId: clientIds[clientId], + }) + +const createPeers = (ids: Array) => + createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds: clientIds, + }) + +const splitThenDeleteBackwardEmptyParagraph = (peer: Peer) => { + const textLength = Editor.string(peer.editor, [0]).length + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: textLength }, + focus: { path: [0, 0], offset: textLength }, + }) + }) + + peer.editor.update((tx) => { + tx.break.insert() + }) + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) + }) + + peer.editor.update((tx) => { + tx.text.deleteBackward({ unit: 'character' }) + }) +} + +const repeatSplitMerge = (peer: Peer, times: number) => { + for (let index = 0; index < times; index++) { + splitThenDeleteBackwardEmptyParagraph(peer) + } +} + +const assertNoLeakedVirtualPlaceholder = (nodes: readonly Descendant[]) => { + for (const node of nodes) { + if (!('children' in node)) { + continue + } + + assert.notEqual(node.type, 'slate-yjs-virtual-placeholder') + assertNoLeakedVirtualPlaceholder(node.children) + } +} + +const assertNoNestedElements = (nodes: readonly Descendant[]) => { + for (const node of nodes) { + if (!('children' in node)) { + continue + } + + assert.equal( + node.children.some((child) => 'children' in child), + false + ) + } +} + +describe('@slate/yjs split and merge collaboration contract', () => { + it('keeps repeated paragraph split and merge from leaking virtual placeholders', () => { + const peers = createPeers(['a', 'b', 'c']) + const [a] = peers + + repeatSplitMerge(a, 2) + syncConnectedPeers(peers) + + for (const peer of peers) { + const snapshotChildren = Editor.getSnapshot(peer.editor).children + + assert.deepEqual(getParagraphTexts(peer), ['Hello world!']) + assertNoLeakedVirtualPlaceholder(snapshotChildren) + assertNoNestedElements(snapshotChildren) + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + paragraph('Hello world!'), + ]) + } + assertNoRootSnapshot(a) + }) + + it('keeps repeated local paragraph split and merge traceable', () => { + const peer = createPeer('b') + + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + repeatSplitMerge(peer, 2) + + const snapshotChildren = Editor.getSnapshot(peer.editor).children + + assert.deepEqual(getParagraphTexts(peer), ['Hello world!']) + assertNoLeakedVirtualPlaceholder(snapshotChildren) + assertNoNestedElements(snapshotChildren) + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + paragraph('Hello world!'), + ]) + assertNoRootSnapshot(peer) + }) + + it('keeps nested virtual placeholder content when splitting a parent element', () => { + const peer = createPeer('b', [ + quote([paragraph('intro'), paragraph('alpha'), paragraph('beta')]), + ]) + + peer.editor.update((tx) => { + tx.nodes.merge({ at: [0, 2] }) + }) + + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + quote([paragraph('intro'), paragraphParts('alpha', 'beta')]), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + path: [0], + position: 1, + properties: { type: 'quote' }, + type: 'split_node', + }, + ]) + }) + + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + quote([paragraph('intro')]), + quote([paragraphParts('alpha', 'beta')]), + ]) + assertNoRootSnapshot(peer) + }) + + it('keeps parent-level virtual move content when merging the adopted target element', () => { + const peer = createPeer('b', [ + quote([paragraph('left')]), + quote([]), + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [1, 0], + path: [2], + type: 'move_node', + }, + ]) + }) + + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + quote([paragraph('left')]), + quote([paragraph('moved')]), + ]) + + peer.editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) + + assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + quote([paragraph('left'), paragraph('moved')]), + ]) + assertNoRootSnapshot(peer) + }) +}) diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts index f674ea81a5..056cab9af7 100644 --- a/packages/slate-yjs/test/split-node-contract.spec.ts +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -18,6 +18,8 @@ const paragraph = (text: string): Descendant => ({ const initialValue = () => [paragraph('alphabeta')] +const helloValue = () => [paragraph('Hello world!')] + const createPeer = ( clientId: string, seedUpdate?: Uint8Array, @@ -42,19 +44,21 @@ const createPeer = ( return { doc, editor } } -const createPeers = (clientIds: string[]) => { +const createPeers = (clientIds: string[], children = initialValue()) => { const [firstClientId, ...remainingClientIds] = clientIds if (!firstClientId) { return [] } - const firstPeer = createPeer(firstClientId) + const firstPeer = createPeer(firstClientId, undefined, children) const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) return [ firstPeer, - ...remainingClientIds.map((clientId) => createPeer(clientId, seedUpdate)), + ...remainingClientIds.map((clientId) => + createPeer(clientId, seedUpdate, children) + ), ] } @@ -124,8 +128,8 @@ const syncConnected = (peers: Peer[]) => { } const assertAllTexts = (peers: Peer[], expected: string[]) => { - for (const peer of peers) { - assert.deepEqual(paragraphTexts(peer), expected) + for (const [index, peer] of peers.entries()) { + assert.deepEqual(paragraphTexts(peer), expected, `peer ${index}`) } } @@ -135,6 +139,12 @@ const splitParagraph = (peer: Peer) => { }) } +const splitHelloParagraph = (peer: Peer) => { + peer.editor.update((tx) => { + tx.nodes.split({ at: { path: [0, 0], offset: 'Hello '.length } }) + }) +} + const insertRemoteTextAtSplitPoint = (peer: Peer) => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alph'.length } }) @@ -147,6 +157,31 @@ const appendRemoteText = (peer: Peer) => { }) } +const appendExclamationToFirstParagraph = (peer: Peer) => { + const offset = Editor.string(peer.editor, [0]).length + + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [0, 0], offset } }) + }) +} + +const insertWorldParagraphAfterFirst = (peer: Peer) => { + const offset = Editor.string(peer.editor, [0]).length + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset }, + focus: { path: [0, 0], offset }, + }) + }) + peer.editor.update((tx) => { + tx.break.insert() + }) + peer.editor.update((tx) => { + tx.text.insert('world! after') + }) +} + const insertTextSplitAndInsertRightText = (peer: Peer) => { peer.editor.update((tx) => { tx.selection.set({ @@ -214,6 +249,76 @@ describe('@slate/yjs split_node collaboration contract', () => { assertAllTexts(peers, ['alph', 'abeta']) }) + it('preserves a remote split when an offline local split was undone before reconnect', () => { + const peers = createPeers(['a', 'b', 'c'], helloValue()) + const [a, b] = peers + + yjsUpdate(a, (yjs) => yjs.disconnect()) + splitHelloParagraph(a) + yjsUpdate(a, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(a), ['Hello world!']) + + splitHelloParagraph(b) + syncConnected(peers) + assert.deepEqual(paragraphTexts(a), ['Hello world!']) + assert.deepEqual(paragraphTexts(b), ['Hello ', 'world!']) + + yjsUpdate(a, (yjs) => yjs.connect()) + syncConnected(peers) + + assertAllTexts(peers, ['Hello ', 'world!']) + assertNoRootSnapshot(a) + }) + + it('replays an offline split redo onto the remote split boundary after reconnect', () => { + const peers = createPeers(['a', 'b', 'c'], helloValue()) + const [a, b] = peers + + yjsUpdate(a, (yjs) => yjs.disconnect()) + splitHelloParagraph(a) + yjsUpdate(a, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(a), ['Hello world!']) + + appendExclamationToFirstParagraph(b) + syncConnected(peers) + splitHelloParagraph(b) + syncConnected(peers) + assert.deepEqual(paragraphTexts(a), ['Hello world!']) + assert.deepEqual(paragraphTexts(b), ['Hello ', 'world!!']) + + yjsUpdate(a, (yjs) => yjs.connect()) + syncConnected(peers) + yjsUpdate(a, (yjs) => yjs.redo()) + syncConnected(peers) + + assertAllTexts(peers, ['Hello ', 'world!!']) + assertNoRootSnapshot(a) + }) + + it('does not absorb a later unrelated paragraph that matches the offline undo suffix', () => { + const peers = createPeers(['a', 'b', 'c'], helloValue()) + const [a, b] = peers + + yjsUpdate(a, (yjs) => yjs.disconnect()) + splitHelloParagraph(a) + yjsUpdate(a, (yjs) => yjs.undo()) + assert.deepEqual(paragraphTexts(a), ['Hello world!']) + + yjsUpdate(a, (yjs) => yjs.connect()) + syncConnected(peers) + assertAllTexts(peers, ['Hello world!']) + + insertWorldParagraphAfterFirst(b) + syncConnected(peers) + assertAllTexts(peers, ['Hello world!', 'world! after']) + + yjsUpdate(a, (yjs) => yjs.redo()) + syncConnected(peers) + + assertAllTexts(peers, ['Hello ', 'world!', 'world! after']) + assertNoRootSnapshot(a) + }) + it('undoes and redoes only the local split intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/structural-soak-contract.spec.ts b/packages/slate-yjs/test/structural-soak-contract.spec.ts new file mode 100644 index 0000000000..111fb830b9 --- /dev/null +++ b/packages/slate-yjs/test/structural-soak-contract.spec.ts @@ -0,0 +1,536 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { Descendant, Operation } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { readSlateValueFromYjs } from '../src/core/document' +import { applySlateOperationToYjs } from '../src/core/operations' +import type { Peer } from './support/collaboration' +import { + createSeededYjsPeers, + createYjsPeer, + FakeAwareness, + getYjsState, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' + +type PeerId = 'a' | 'b' | 'c' | 'd' + +const clientIds: Record = { + a: 101, + b: 202, + c: 303, + d: 404, +} + +const appendTexts: Record = { + a: ' Ada', + b: ' Lin', + c: ' Ken', + d: ' Eve', +} + +const paragraph = (text: string): Descendant => ({ + children: [{ text }], + type: 'paragraph', +}) + +const initialValue = () => [paragraph('Hello world!')] + +const createPeers = () => { + const peers = createSeededYjsPeers({ + children: initialValue(), + clientIds: ['a', 'b', 'c', 'd'], + numericClientIds: clientIds, + }) + + return Object.fromEntries( + (['a', 'b', 'c', 'd'] as const).map((id, index) => [id, peers[index]!]) + ) as Record +} + +const createAwarePeers = () => { + const first = createYjsPeer({ + awareness: new FakeAwareness(clientIds.a), + children: initialValue(), + clientId: 'a', + numericClientId: clientIds.a, + }) + const seedUpdate = Y.encodeStateAsUpdate(first.doc) + const peers = { + a: first, + b: createYjsPeer({ + awareness: new FakeAwareness(clientIds.b), + children: initialValue(), + clientId: 'b', + numericClientId: clientIds.b, + seedUpdate, + }), + c: createYjsPeer({ + awareness: new FakeAwareness(clientIds.c), + children: initialValue(), + clientId: 'c', + numericClientId: clientIds.c, + seedUpdate, + }), + d: createYjsPeer({ + awareness: new FakeAwareness(clientIds.d), + children: initialValue(), + clientId: 'd', + numericClientId: clientIds.d, + seedUpdate, + }), + } + + return peers +} + +const allPeers = (peers: Record) => + ['a', 'b', 'c', 'd'].map((id) => peers[id as PeerId]) + +const editorValueOf = (peer: Peer) => + Editor.getSnapshot(peer.editor).children as Descendant[] + +type TextEntry = { + path: number[] + text: string +} + +const isText = (node: Descendant): node is Descendant & { text: string } => + 'text' in node + +const hasChildren = ( + node: Descendant +): node is Descendant & { children: readonly Descendant[] } => + 'children' in node && Array.isArray(node.children) + +const findTextEntryInNode = ( + node: Descendant, + path: number[], + direction: 'first' | 'last' +): TextEntry | null => { + if (isText(node)) { + return { path, text: node.text } + } + + if (!hasChildren(node)) { + return null + } + + const start = direction === 'first' ? 0 : node.children.length - 1 + const end = direction === 'first' ? node.children.length : -1 + const step = direction === 'first' ? 1 : -1 + + for (let index = start; index !== end; index += step) { + const child = node.children[index] + + if (!child) { + continue + } + + const entry = findTextEntryInNode(child, [...path, index], direction) + + if (entry) { + return entry + } + } + + return null +} + +const firstBlockTextEntry = (peer: Peer, direction: 'first' | 'last') => { + const [block] = editorValueOf(peer) + + return block ? findTextEntryInNode(block, [0], direction) : null +} + +const topLevelCount = (peer: Peer) => editorValueOf(peer).length + +const paragraphTextsOf = (peer: Peer) => + editorValueOf(peer).map((_, index) => Editor.string(peer.editor, [index])) + +const assertPeerParagraphTexts = ( + peers: readonly Peer[], + expected: readonly string[] +) => { + for (const peer of peers) { + assert.deepEqual(paragraphTextsOf(peer), expected) + } +} + +const firstBlockIsQuote = (peer: Peer) => { + const [firstBlock] = editorValueOf(peer) + + return ( + !!firstBlock && 'type' in firstBlock && firstBlock.type === 'block-quote' + ) +} + +const hasNestedParagraph = ( + node: Descendant, + insideParagraph = false +): boolean => { + if (!hasChildren(node)) { + return false + } + + const isParagraph = 'type' in node && node.type === 'paragraph' + + if (insideParagraph && isParagraph) { + return true + } + + return node.children.some((child) => + hasNestedParagraph(child, insideParagraph || isParagraph) + ) +} + +const assertNoNestedParagraphs = (peers: readonly Peer[]) => { + for (const peer of peers) { + const value = editorValueOf(peer) + + assert.equal( + value.some((node) => hasNestedParagraph(node)), + false, + JSON.stringify(value) + ) + assert.equal( + readSlateValueFromYjs(getYjsState(peer).root()).some((node) => + hasNestedParagraph(node) + ), + false + ) + } +} + +const sync = (peers: Record) => { + const peerList = allPeers(peers) + + syncConnectedPeers(peerList) + + for (const peer of peerList) { + if (!getYjsState(peer).connected()) { + continue + } + + runYjsUpdate(peer, (yjs) => yjs.reconcile()) + } +} + +const runCommand = ( + peers: Record, + peerId: PeerId, + command: (peer: Peer, peerId: PeerId) => void +) => { + command(peers[peerId], peerId) + sync(peers) +} + +const setConnected = ( + peers: Record, + peerId: PeerId, + connected: boolean +) => { + runYjsUpdate(peers[peerId], (yjs) => + connected ? yjs.connect() : yjs.disconnect() + ) + sync(peers) +} + +const appendText = (peer: Peer, peerId: PeerId) => { + const entry = firstBlockTextEntry(peer, 'last') + + if (!entry) { + return + } + + peer.editor.update((tx) => { + tx.text.insert(appendTexts[peerId], { + at: { path: entry.path, offset: entry.text.length }, + }) + }) +} + +const splitFirstText = (peer: Peer) => { + const entry = firstBlockTextEntry(peer, 'first') + + if (!entry || entry.text.length < 2) { + return + } + + const offset = Math.max(1, Math.floor(entry.text.length / 2)) + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, + }) + tx.break.insert() + }) +} + +const ensureTopLevelCount = (peer: Peer, count: number) => { + const current = topLevelCount(peer) + + if (current >= count) { + return + } + + peer.editor.update((tx) => { + for (let index = current; index < count; index++) { + tx.nodes.insert(paragraph(`block ${index + 1}`), { at: [index] }) + } + }) +} + +const moveFirstBlockDown = (peer: Peer) => { + ensureTopLevelCount(peer, 2) + + peer.editor.update((tx) => { + tx.nodes.move({ at: [0], to: [1] }) + }) +} + +const moveFirstBlockAfterSecond = (peer: Peer) => { + if (topLevelCount(peer) < 2) { + return + } + + peer.editor.update((tx) => { + tx.nodes.move({ at: [0], to: [1] }) + }) +} + +const mergeSecondBlock = (peer: Peer) => { + if (topLevelCount(peer) < 2) { + return + } + + peer.editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) +} + +const removeSecondBlock = (peer: Peer) => { + if (topLevelCount(peer) < 2) { + return + } + + peer.editor.update((tx) => { + tx.nodes.remove({ at: [1] }) + }) +} + +const wrapFirstBlock = (peer: Peer) => { + peer.editor.update((tx) => { + tx.selection.clear() + tx.nodes.wrap({ children: [], type: 'block-quote' }, { at: [0] }) + tx.selection.clear() + }) +} + +const unwrapFirstBlock = (peer: Peer) => { + if (!firstBlockIsQuote(peer)) { + return + } + + peer.editor.update((tx) => { + tx.nodes.unwrap({ at: [0] }) + }) +} + +const liftFirstWrappedBlock = (peer: Peer) => { + if (!firstBlockIsQuote(peer)) { + return + } + + peer.editor.update((tx) => { + tx.nodes.lift({ at: [0, 0] }) + }) +} + +const unsetFirstBlockRole = (peer: Peer) => { + const [firstBlock] = editorValueOf(peer) + + if (!firstBlock || !('role' in firstBlock)) { + return + } + + peer.editor.update((tx) => { + tx.nodes.unset('role' as never, { at: [0] }) + }) +} + +const insertExclamation = (peer: Peer) => { + const entry = firstBlockTextEntry(peer, 'last') + + if (!entry) { + return + } + + peer.editor.update((tx) => { + tx.text.insert('!', { + at: { path: entry.path, offset: entry.text.length }, + }) + }) +} + +const deleteFirstFragment = (peer: Peer) => { + const entry = firstBlockTextEntry(peer, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) + + if (length === 0) { + return + } + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, + }) + tx.fragment.delete() + }) +} + +const reconcilePeer = (peer: Peer) => { + runYjsUpdate(peer, (yjs) => yjs.reconcile()) +} + +const assertDocumentHasTextBoundary = (peers: readonly Peer[]) => { + for (const peer of peers) { + const value = editorValueOf(peer) + + assert.notEqual( + firstBlockTextEntry(peer, 'first'), + null, + JSON.stringify(value) + ) + assert.notEqual( + firstBlockTextEntry(peer, 'last'), + null, + JSON.stringify(value) + ) + } +} + +describe('@slate/yjs structural soak contract', () => { + it('keeps random-control seed 10 prefix from nesting paragraphs', () => { + const peers = createPeers() + + runCommand(peers, 'a', wrapFirstBlock) + runCommand(peers, 'c', appendText) + runCommand(peers, 'a', splitFirstText) + runCommand(peers, 'c', moveFirstBlockDown) + runCommand(peers, 'c', mergeSecondBlock) + + assertNoNestedParagraphs(allPeers(peers)) + }) + + it('keeps offline structural mix seed 3 from producing stale leaf paths', () => { + const peers = createPeers() + + setConnected(peers, 'a', false) + runCommand(peers, 'a', unsetFirstBlockRole) + runCommand(peers, 'd', liftFirstWrappedBlock) + runCommand(peers, 'a', moveFirstBlockDown) + runCommand(peers, 'd', wrapFirstBlock) + runCommand(peers, 'a', moveFirstBlockDown) + runCommand(peers, 'b', unwrapFirstBlock) + + assertNoNestedParagraphs(allPeers(peers)) + assertPeerParagraphTexts([peers.a], ['Hello world!', 'block 2']) + assertPeerParagraphTexts([peers.b, peers.c, peers.d], ['Hello world!']) + + setConnected(peers, 'a', true) + + assertPeerParagraphTexts(allPeers(peers), ['Hello world!', 'block 2']) + }) + + it('exports selection after structural unwrap only when the Yjs target is text', () => { + const peers = createAwarePeers() + + runCommand(peers, 'd', wrapFirstBlock) + peers.b.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0], offset: 0 }, + focus: { path: [0], offset: 0 }, + }) + }) + + assert.doesNotThrow(() => { + runCommand(peers, 'b', unwrapFirstBlock) + }) + }) + + it('keeps random-control seed 42 from missing Yjs path 1.0', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', insertExclamation) + runCommand(peers, 'c', wrapFirstBlock) + runCommand(peers, 'b', appendText) + runCommand(peers, 'b', splitFirstText) + reconcilePeer(peers.d) + runCommand(peers, 'd', moveFirstBlockAfterSecond) + runCommand(peers, 'c', removeSecondBlock) + runCommand(peers, 'c', insertExclamation) + setConnected(peers, 'a', true) + setConnected(peers, 'd', false) + runCommand(peers, 'c', mergeSecondBlock) + + assert.doesNotThrow(() => { + runCommand(peers, 'c', unwrapFirstBlock) + }) + assertNoNestedParagraphs(allPeers(peers)) + assertPeerParagraphTexts([peers.a, peers.b, peers.c], ['Hello wo']) + assertPeerParagraphTexts([peers.d], ['Hello world!! Lin!']) + + setConnected(peers, 'd', true) + + assertPeerParagraphTexts(allPeers(peers), ['Hello wo']) + }) + + it('elides stale move_node source paths after concurrent structural removal', () => { + const peer = createPeers().a + const operation: Operation = { + newPath: [1], + path: [1, 0], + root: 'main', + type: 'move_node', + } + + assert.deepEqual( + applySlateOperationToYjs(getYjsState(peer).root(), operation), + { + fallback: 'missing-move-source-elided', + mode: 'traceable-fallback', + operationType: 'move_node', + } + ) + }) + + it('keeps offline structural mix seed 16 from losing root text boundaries', () => { + const peers = createAwarePeers() + + setConnected(peers, 'a', false) + runCommand(peers, 'a', mergeSecondBlock) + runCommand(peers, 'd', splitFirstText) + runCommand(peers, 'a', wrapFirstBlock) + runCommand(peers, 'c', splitFirstText) + runCommand(peers, 'a', deleteFirstFragment) + runCommand(peers, 'c', moveFirstBlockDown) + runCommand(peers, 'a', wrapFirstBlock) + runCommand(peers, 'd', splitFirstText) + setConnected(peers, 'a', true) + + assertNoNestedParagraphs(allPeers(peers)) + assertDocumentHasTextBoundary(allPeers(peers)) + assertPeerParagraphTexts(allPeers(peers), ['', 'l', 'o ', '', 'world!']) + }) +}) diff --git a/packages/slate-yjs/test/support/collaboration.ts b/packages/slate-yjs/test/support/collaboration.ts index 0327fbea5d..4a34108282 100644 --- a/packages/slate-yjs/test/support/collaboration.ts +++ b/packages/slate-yjs/test/support/collaboration.ts @@ -5,9 +5,14 @@ import * as Y from 'yjs' import { createYjsExtension } from '../../src' import { getYjsNode } from '../../src/core/document' -import type { YjsAwarenessChange, YjsAwarenessLike } from '../../src/core/types' +import type { + YjsAwarenessChange, + YjsAwarenessLike, + YjsProviderLike, +} from '../../src/core/types' export type Peer = { + cleanup: () => void doc: Y.Doc editor: ReturnType } @@ -79,12 +84,14 @@ export const createYjsPeer = ({ awareness, clientId, numericClientId, + provider, seedUpdate, }: { awareness?: YjsAwarenessLike children: Descendant[] clientId: string numericClientId?: number + provider?: YjsProviderLike seedUpdate?: Uint8Array }): Peer => { const editor = createEditor() @@ -105,11 +112,17 @@ export const createYjsPeer = ({ Y.applyUpdate(doc, seedUpdate) } - editor.extend( - createYjsExtension({ awareness, clientId, doc, rootName: 'slate' }) + const cleanup = editor.extend( + createYjsExtension({ + awareness, + clientId, + doc, + provider, + rootName: 'slate', + }) ) - return { doc, editor } + return { cleanup, doc, editor } } export const createSeededYjsPeers = ({ diff --git a/packages/slate-yjs/tsconfig.json b/packages/slate-yjs/tsconfig.json index 5c93cc86cf..2a46f19d76 100644 --- a/packages/slate-yjs/tsconfig.json +++ b/packages/slate-yjs/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../config/typescript/tsconfig.json", "include": ["src/**/*"], "compilerOptions": { - "types": [] + "jsx": "react-jsx", + "types": ["node", "react"] } } diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts index e26c79298a..25f09a5ed0 100644 --- a/playwright/integration/examples/yjs-collaboration.test.ts +++ b/playwright/integration/examples/yjs-collaboration.test.ts @@ -3,6 +3,33 @@ import { expect, type Page, test } from '@playwright/test' import { openExample } from 'slate-browser/playwright' type PeerId = 'a' | 'b' | 'c' | 'd' +type YjsPeerControl = + | 'append' + | 'connect' + | 'delete-backward' + | 'delete-fragment' + | 'disconnect' + | 'insert-fragment' + | 'insert-text' + | 'lift' + | 'merge-node' + | 'move' + | 'move-down' + | 'reconcile' + | 'redo' + | 'remove-node' + | 'replace' + | 'set-node' + | 'split-node' + | 'undo' + | 'unset-node' + | 'unwrap' + | 'wrap-node' + +type YjsPeerAction = readonly [peer: PeerId, control: YjsPeerControl] + +const structuralBrowserErrorPattern = + /Slate point does not target a Y\.XmlText|Cannot get the leaf node|No Yjs node at path|Cannot descend into Y\.XmlText|Yjs parent is text|start text node|end text node|

cannot be a descendant of

|validateDOMNesting/i const byTestId = (page: Page, id: string) => page.locator(`[data-test-id="${id}"]`) @@ -32,18 +59,111 @@ const getPeerParagraphTexts = (page: Page, peer: PeerId) => }) ) +const getPeerTopLevelBlockTexts = (page: Page, peer: PeerId) => + peerTextbox(page, peer).evaluate((textbox) => + [...textbox.children].map((block) => { + const clone = block.cloneNode(true) as HTMLElement + + clone + .querySelectorAll('[data-slate-placeholder="true"]') + .forEach((placeholder) => { + placeholder.remove() + }) + + return (clone.textContent ?? '').replaceAll('\uFEFF', '') + }) + ) + const expectAllPeerParagraphTexts = async (page: Page, expected: string[]) => { for (const peer of ['a', 'b', 'c', 'd'] as const) { await expect.poll(() => getPeerParagraphTexts(page, peer)).toEqual(expected) } } +const expectAllPeerTopLevelBlockTexts = async ( + page: Page, + expected: string[] +) => { + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerTopLevelBlockTexts(page, peer)) + .toEqual(expected) + } +} + const expectNoPeerBlockQuotes = async (page: Page) => { for (const peer of ['a', 'b', 'c', 'd'] as const) { await expect(peerTextbox(page, peer).locator('blockquote')).toHaveCount(0) } } +const expectNoPeerNestedParagraphs = async (page: Page) => { + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect(peerTextbox(page, peer).locator('p p')).toHaveCount(0) + } +} + +const watchStructuralBrowserErrors = (page: Page) => { + const errors: string[] = [] + + page.on('pageerror', (error) => { + errors.push(String(error.stack || error.message || error)) + }) + page.on('console', (message) => { + if (message.type() !== 'error') { + return + } + + const text = message.text() + + if (structuralBrowserErrorPattern.test(text)) { + errors.push(text) + } + }) + + return errors +} + +const clickPeerControl = async ( + page: Page, + peer: PeerId, + control: YjsPeerControl +) => { + const button = byTestId(page, `yjs-peer-${peer}-${control}`) + + await expect(button).toBeVisible() + + if (await button.isDisabled()) { + return false + } + + await button.click() + + return true +} + +const runPeerActions = async ( + page: Page, + actions: readonly YjsPeerAction[], + options: { errors?: readonly string[] } = {} +) => { + for (const [peer, control] of actions) { + await test.step(`${peer} ${control}`, async () => { + await clickPeerControl(page, peer, control) + await page.waitForTimeout(0) + if (options.errors) { + expect(options.errors, `${peer} ${control}`).toEqual([]) + } + }) + } +} + +const expectAllPeerTextboxesAlive = async (page: Page) => { + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect(peerTextbox(page, peer)).toBeVisible() + } +} + const getPeerLayoutProof = (page: Page, peer: PeerId) => peerTextbox(page, peer).evaluate((textbox) => { const editorRect = textbox.getBoundingClientRect() @@ -204,14 +324,18 @@ const selectPeerTextRange = async ( anchorOffset: number, focusOffset: number ) => { + const modelRange = { + anchor: { path: [paragraphIndex, 0], offset: anchorOffset }, + focus: { path: [paragraphIndex, 0], offset: focusOffset }, + } + if (anchorOffset === focusOffset) { await placePeerCaret(page, peer, paragraphIndex, anchorOffset) return } - await peerTextbox(page, peer).click() await page.evaluate( - ({ anchorOffset, focusOffset, paragraphIndex, peer }) => { + ({ anchorOffset, focusOffset, modelRange, paragraphIndex, peer }) => { const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) const textbox = root?.querySelector('[role="textbox"]') const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] @@ -280,15 +404,33 @@ const selectPeerTextRange = async ( } ).__slateBrowserHandle - handle?.selectRange?.({ - anchor: { path: [paragraphIndex, 0], offset: anchorOffset }, - focus: { path: [paragraphIndex, 0], offset: focusOffset }, - }) + if (!handle?.selectRange) { + throw new Error(`Peer ${peer} browser handle not ready`) + } + + handle.selectRange(modelRange) document.dispatchEvent(new Event('selectionchange')) }, - { anchorOffset, focusOffset, paragraphIndex, peer } + { anchorOffset, focusOffset, modelRange, paragraphIndex, peer } ) + + await expect + .poll(() => + peerTextbox(page, peer).evaluate((textbox) => + ( + textbox as HTMLElement & { + __slateBrowserHandle?: { + getSelection: () => { + anchor: { offset: number; path: number[] } + focus: { offset: number; path: number[] } + } | null + } + } + ).__slateBrowserHandle?.getSelection() + ) + ) + .toEqual(modelRange) } const selectPeerSlateRange = async ( @@ -299,7 +441,6 @@ const selectPeerSlateRange = async ( focus: { offset: number; path: number[] } } ) => { - await peerTextbox(page, peer).click() await page.evaluate( ({ peer, range }) => { const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) @@ -1075,6 +1216,80 @@ test.describe('yjs collaboration example', () => { } }) + test('preserves remote split when offline split is undone before reconnect', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-disconnect').click() + await byTestId(page, 'yjs-peer-a-split-node').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['Hello ', 'world!']) + + await byTestId(page, 'yjs-peer-a-undo').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['Hello world!']) + + await byTestId(page, 'yjs-peer-b-split-node').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Hello ', 'world!']) + + await byTestId(page, 'yjs-peer-a-connect').click() + + await expectAllPeerParagraphTexts(page, ['Hello ', 'world!']) + expect(pageErrors).toEqual([]) + }) + + test('replays offline split redo onto remote split boundary after reconnect', async ({ + page, + }) => { + const pageErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await byTestId(page, 'yjs-peer-a-disconnect').click() + await byTestId(page, 'yjs-peer-a-split-node').click() + await byTestId(page, 'yjs-peer-a-undo').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual(['Hello world!']) + + await byTestId(page, 'yjs-peer-b-insert-text').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Hello world!!']) + + await byTestId(page, 'yjs-peer-b-split-node').click() + await expect + .poll(() => getPeerParagraphTexts(page, 'b')) + .toEqual(['Hello ', 'world!!']) + + await byTestId(page, 'yjs-peer-a-connect').click() + await byTestId(page, 'yjs-peer-a-redo').click() + + await expectAllPeerParagraphTexts(page, ['Hello ', 'world!!']) + expect(pageErrors).toEqual([]) + }) + test('keeps public split button undo converged after reconnect', async ({ page, }) => { @@ -1256,6 +1471,258 @@ test.describe('yjs collaboration example', () => { } }) + test('survives offline structural mix seed 3 without stale leaf paths', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions(page, [ + ['a', 'disconnect'], + ['a', 'unset-node'], + ['d', 'lift'], + ['a', 'move-down'], + ['d', 'wrap-node'], + ['a', 'move-down'], + ['b', 'unwrap'], + ]) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + await expect + .poll(() => getPeerTopLevelBlockTexts(page, 'a')) + .toEqual(['Hello world!', 'block 2']) + for (const peer of ['b', 'c', 'd'] as const) { + await expect + .poll(() => getPeerTopLevelBlockTexts(page, peer)) + .toEqual(['Hello world!']) + } + + await runPeerActions(page, [['a', 'connect']], { errors }) + + await expectAllPeerTopLevelBlockTexts(page, ['Hello world!', 'block 2']) + expect(errors).toEqual([]) + }) + + test('keeps random-control seed 10 prefix from rendering nested paragraphs', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions(page, [ + ['a', 'wrap-node'], + ['c', 'append'], + ['a', 'split-node'], + ['c', 'move-down'], + ['c', 'merge-node'], + ]) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + expect(errors).toEqual([]) + }) + + test('keeps offline structural mix seed 16 from losing root text boundaries', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions(page, [ + ['a', 'disconnect'], + ['a', 'merge-node'], + ['d', 'split-node'], + ['a', 'wrap-node'], + ['c', 'split-node'], + ['a', 'delete-fragment'], + ['c', 'move'], + ['a', 'wrap-node'], + ['d', 'split-node'], + ['a', 'connect'], + ]) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + await expectAllPeerTopLevelBlockTexts(page, ['', 'l', 'o ', '', 'world!']) + expect(errors).toEqual([]) + }) + + test('keeps random-control seed 75 from losing root end text boundaries', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions(page, [ + ['b', 'insert-text'], + ['a', 'delete-backward'], + ['b', 'undo'], + ['c', 'insert-fragment'], + ['c', 'disconnect'], + ['d', 'split-node'], + ['d', 'unwrap'], + ['a', 'move'], + ['a', 'delete-backward'], + ['d', 'merge-node'], + ['b', 'redo'], + ['c', 'unset-node'], + ['d', 'delete-fragment'], + ]) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + expect(errors).toEqual([]) + }) + + test('keeps structural warm-up seed 17 from repeating stale leaf paths', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions(page, [ + ['a', 'disconnect'], + ['c', 'split-node'], + ['a', 'merge-node'], + ['b', 'unwrap'], + ['a', 'delete-fragment'], + ['b', 'wrap-node'], + ['a', 'unset-node'], + ['c', 'insert-text'], + ]) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + expect(errors).toEqual([]) + }) + + test('keeps random-control seed 42 from missing Yjs path 1.0', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'insert-text'], + ['c', 'wrap-node'], + ['b', 'append'], + ['b', 'split-node'], + ['d', 'reconcile'], + ['d', 'move'], + ['c', 'remove-node'], + ['c', 'insert-text'], + ['a', 'connect'], + ['d', 'disconnect'], + ['c', 'merge-node'], + ['c', 'unwrap'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + for (const peer of ['a', 'b', 'c'] as const) { + await expect + .poll(() => getPeerTopLevelBlockTexts(page, peer)) + .toEqual(['Hello wo']) + } + await expect + .poll(() => getPeerTopLevelBlockTexts(page, 'd')) + .toEqual(['Hello world!! Lin!']) + await runPeerActions(page, [['d', 'connect']], { errors }) + await expectAllPeerTopLevelBlockTexts(page, ['Hello wo']) + expect(errors).toEqual([]) + }) + + test('keeps random-control seed 96 from repeating missing Yjs path 1.0', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'reconcile'], + ['c', 'wrap-node'], + ['b', 'delete-fragment'], + ['c', 'set-node'], + ['b', 'split-node'], + ['b', 'move'], + ['b', 'unwrap'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + expect(errors).toEqual([]) + }) + + test('keeps random-control seed 115 from repeating missing Yjs path 1.0', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'undo'], + ['c', 'connect'], + ['a', 'unset-node'], + ['d', 'reconcile'], + ['a', 'wrap-node'], + ['d', 'move'], + ['d', 'insert-fragment'], + ['b', 'split-node'], + ['b', 'insert-text'], + ['c', 'unset-node'], + ['d', 'unwrap'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerNestedParagraphs(page) + expect(errors).toEqual([]) + }) + test('preserves concurrent text when an offline wrap button reconnects', async ({ page, }) => { @@ -1418,6 +1885,42 @@ test.describe('yjs collaboration example', () => { expect(pageErrors).toEqual([]) }) + test('keeps repeated keyboard split and merge from leaking virtual placeholders', async ({ + page, + }) => { + const pageErrors: string[] = [] + const text = 'Hello world!' + + page.on('pageerror', (error) => { + pageErrors.push(String(error.message || error)) + }) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + for (let index = 0; index < 2; index++) { + await selectPeerSlateRange(page, 'a', { + anchor: { path: [0, 0], offset: text.length }, + focus: { path: [0, 0], offset: text.length }, + }) + await page.keyboard.press('Enter') + await expect + .poll(() => getPeerParagraphTexts(page, 'a')) + .toEqual([text, '']) + await selectPeerSlateRange(page, 'a', { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) + await page.keyboard.press('Backspace') + } + + await expectAllPeerParagraphTexts(page, [text]) + await expectNoPeerNestedParagraphs(page) + expect(pageErrors).toEqual([]) + }) + test('keeps connected fragment button edits converged without page errors', async ({ page, }) => { From aa2c2a73971052d3c3dfc9e2da7bbb012d471af0 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sun, 7 Jun 2026 21:18:05 +0800 Subject: [PATCH 05/11] chore(autoresearch): track yjs-pr21 collaboration quality routing Record the yjs-pr21 research run (brief, plan, synthesis, quality gaps, sources, tasks, and soak/split-merge bug notes) and route gaps to the slate-ar pipeline. --- autoresearch.jsonl | 1 + autoresearch.md | 24 +- .../yjs-pr20/quality-gaps.md | 1 - autoresearch.research/yjs-pr20/synthesis.md | 7 +- autoresearch.research/yjs-pr20/tasks.md | 2 +- autoresearch.research/yjs-pr21/brief.md | 19 + .../virtual-placeholder-split-merge-bug.md | 172 ++++++++ ...-collaboration-soak-findings-2026-06-03.md | 371 ++++++++++++++++++ autoresearch.research/yjs-pr21/plan.md | 13 + .../yjs-pr21/quality-gaps.md | 25 ++ autoresearch.research/yjs-pr21/sources.md | 16 + autoresearch.research/yjs-pr21/synthesis.md | 25 ++ autoresearch.research/yjs-pr21/tasks.md | 21 + autoresearch.sh | 2 +- 14 files changed, 679 insertions(+), 20 deletions(-) create mode 100644 autoresearch.research/yjs-pr21/brief.md create mode 100644 autoresearch.research/yjs-pr21/notes/virtual-placeholder-split-merge-bug.md create mode 100644 autoresearch.research/yjs-pr21/notes/yjs-collaboration-soak-findings-2026-06-03.md create mode 100644 autoresearch.research/yjs-pr21/plan.md create mode 100644 autoresearch.research/yjs-pr21/quality-gaps.md create mode 100644 autoresearch.research/yjs-pr21/sources.md create mode 100644 autoresearch.research/yjs-pr21/synthesis.md create mode 100644 autoresearch.research/yjs-pr21/tasks.md diff --git a/autoresearch.jsonl b/autoresearch.jsonl index 3c907b9bff..860ae80702 100644 --- a/autoresearch.jsonl +++ b/autoresearch.jsonl @@ -1 +1,2 @@ {"type":"config","name":"Deep research: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf.","goal":"Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf.","metricName":"quality_gap","metricUnit":"gaps","bestDirection":"lower"} +{"type":"config","name":"Deep research: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar.","goal":"Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar.","metricName":"quality_gap","metricUnit":"gaps","bestDirection":"lower"} diff --git a/autoresearch.md b/autoresearch.md index 908569aa0c..4ce2aaa410 100644 --- a/autoresearch.md +++ b/autoresearch.md @@ -1,7 +1,7 @@ -# Autoresearch: Deep research: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. +# Autoresearch: Deep research: yjs-pr21 @slate/yjs collaboration quality routing ## Objective -Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. +Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. ## Metrics - Primary: quality_gap (gaps, lower is better) @@ -11,14 +11,14 @@ Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/r `./autoresearch.sh` prints `METRIC name=value` lines. ## Files in Scope -- autoresearch.research/yjs-pr20 +- autoresearch.research/yjs-pr21 ## Off Limits - TBD: add off-limits files or behaviors if needed ## Constraints -- - Decision contract: quality_gap is treated as a quality-bearing score; faster runs should not be promoted when component evidence shows quality or correctness erosion. -- Keep research notes under autoresearch.research/yjs-pr20. +- Decision contract: quality_gap is treated as a quality-bearing score; faster runs should not be promoted when component evidence shows quality or correctness erosion. +- Keep research notes under autoresearch.research/yjs-pr21. - Use source-backed evidence before implementing recommendations. ## Decision Rules @@ -37,16 +37,18 @@ Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/r - For deep research loops, link the scratchpad folder and summarize the current synthesis. ## What's Been Tried -- Baseline: `scripts/benchmarks/core/current/yjs-collaboration.mjs` measures real `@slate/yjs` multi-editor sync, awareness updates, reconnect, and large-doc sync. Primary metric `yjs_collaboration_worst_p95_ms=122.33`; focused package gates and `bun check` pass; existing Yjs browser offline replace failure blocks optimization promotion. +- yjs-pr21 research setup created `autoresearch.research/yjs-pr21`. +- Live source inspection found four accepted routed gaps: provider lifecycle/API (`slate-plan`), remote cursor rendering (`slate-plan`), operation encoder exhaustiveness (`slate-patch`), and named collaboration release gate (`slate-ar-gate`). +- This round rejected `slate-ar-perf`: a Yjs collaboration benchmark exists, but no current metric regression or threshold miss was identified. ## Resume This Session Use these commands to pick the loop back up without rediscovering state: ```bash -node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" state --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" -node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" doctor --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" --check-benchmark -node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" next --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" -node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" log --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" --from-last --status keep --description "Describe the kept change" -node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" export --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.2/scripts/autoresearch.mjs" state --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.2/scripts/autoresearch.mjs" quality-gap --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" --research-slug yjs-pr21 --list +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.2/scripts/autoresearch.mjs" benchmark-lint --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" --metric-name quality_gap --command "bash ./autoresearch.sh" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.2/scripts/autoresearch.mjs" serve --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" +node "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.2/scripts/autoresearch.mjs" export --cwd "/Users/felixfeng/Desktop/repos/plate-copy/.tmp/slate-v2" ``` diff --git a/autoresearch.research/yjs-pr20/quality-gaps.md b/autoresearch.research/yjs-pr20/quality-gaps.md index 751ab55de4..24d34fe121 100644 --- a/autoresearch.research/yjs-pr20/quality-gaps.md +++ b/autoresearch.research/yjs-pr20/quality-gaps.md @@ -15,7 +15,6 @@ | Remote cursor rendering is underpowered. | `slate-plan` | React package exposes range hooks only; y-prosemirror and old slate-yjs ship render/decorate helpers. | Plan a first-party React cursor decoration/rendering API with tests for caret, range, data, local-user filtering, blur cleanup, and field names. | | Collaboration proof lacks a named release gate. | `slate-ar-gate` | Browser suite has the needed oracles but no single release gate definition. | Gate command bundle: `bun test ./packages/slate-yjs/test`, `bun --filter @slate/yjs typecheck`, and focused Playwright Yjs collaboration greps for reconnect, undo/redo, awareness, and selection. | | Operation encoder exhaustiveness is not explicit enough. | `slate-patch` | `applySlateOperationToYjs` covers the current union but has no visible `never` assertion or operation coverage table. | Add failing-first contract, then an exhaustive guard that fails when Slate adds an operation without a Yjs decision. | -| Public examples are fixture-heavy and not provider-realistic. | `slate-plan` | The current demo hand-rolls local networking and undo group UI for deterministic proof. | Plan a copy-paste provider-backed example separate from the four-peer test matrix. | ## Rejected Candidates diff --git a/autoresearch.research/yjs-pr20/synthesis.md b/autoresearch.research/yjs-pr20/synthesis.md index 7f51df6b54..e3821de312 100644 --- a/autoresearch.research/yjs-pr20/synthesis.md +++ b/autoresearch.research/yjs-pr20/synthesis.md @@ -37,14 +37,9 @@ The current package is not a scaffold. It has operation-family contract tests an Impact: future operation additions can become an accidental no-op or unreviewed fallback path. This is a correctness guard gap, not a known user bug. Route: `slate-patch`. -5. Public examples are fixture-heavy and not provider-realistic. - Evidence: the current demo hand-rolls local networking and undo group UI for deterministic proof. - Impact: users need a minimal provider-backed example showing `createYjsExtension`, awareness, remote cursor rendering, connection status, reconnect, cleanup, and history keys without the test harness machinery. - Route: `slate-plan`. - ## Quality-Gap Translation -The accepted routes are recorded in `quality-gaps.md`: `slate-plan` owns provider lifecycle API, React cursor rendering API, and the provider-backed example; `slate-ar-gate` owns the named release proof bundle; `slate-patch` owns the operation encoder exhaustiveness guard. No `slate-ar-perf` route is accepted in this round because no perf metric or trace implicated Yjs collaboration. +The accepted routes are recorded in `quality-gaps.md`: `slate-plan` owns provider lifecycle API and React cursor rendering API; `slate-ar-gate` owns the named release proof bundle; `slate-patch` owns the operation encoder exhaustiveness guard. No `slate-ar-perf` route is accepted in this round because no perf metric or trace implicated Yjs collaboration. ## Confidence And Gaps diff --git a/autoresearch.research/yjs-pr20/tasks.md b/autoresearch.research/yjs-pr20/tasks.md index d7e1e2b036..97553f7bec 100644 --- a/autoresearch.research/yjs-pr20/tasks.md +++ b/autoresearch.research/yjs-pr20/tasks.md @@ -1,7 +1,7 @@ # Research Tasks: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, or slate-ar-perf. ## queued -- Run routed follow-ups: `slate-plan` for provider lifecycle/cursor rendering/provider-backed example. +- Run routed follow-ups: `slate-plan` for provider lifecycle and cursor rendering. - Run routed follow-up: `slate-ar-gate` for named Yjs release proof. - Run routed follow-up: `slate-patch` for operation encoder exhaustiveness. - Run routed follow-up: `slate-patch` for stable Yjs offline replace Playwright failure. diff --git a/autoresearch.research/yjs-pr21/brief.md b/autoresearch.research/yjs-pr21/brief.md new file mode 100644 index 0000000000..19bb3c559f --- /dev/null +++ b/autoresearch.research/yjs-pr21/brief.md @@ -0,0 +1,19 @@ +# Research Brief: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +## Request +Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +## Decision To Support +- Identify source-backed changes worth testing through an autoresearch loop. + +## Success Criteria +- The project essence is accurate. +- Sources and direct evidence are logged. +- High-impact findings are converted into quality gaps. +- Each implemented or rejected gap has evidence. + +## Constraints +- TBD: add constraints as they are discovered + +## Known Unknowns +- TBD: add unresolved questions before delegating or implementing. diff --git a/autoresearch.research/yjs-pr21/notes/virtual-placeholder-split-merge-bug.md b/autoresearch.research/yjs-pr21/notes/virtual-placeholder-split-merge-bug.md new file mode 100644 index 0000000000..51e824d5b3 --- /dev/null +++ b/autoresearch.research/yjs-pr21/notes/virtual-placeholder-split-merge-bug.md @@ -0,0 +1,172 @@ +# Yjs virtual placeholder split/merge bug + +Date: 2026-06-03 +Route scope: +- `/examples/yjs-collaboration` + +## Summary + +Repeated paragraph split/merge operations can corrupt the Yjs-backed Slate tree. +The failure is not a React hydration bug. React only exposes the corrupted Slate +value when a leaked internal virtual placeholder is rendered as a paragraph. + +The high-risk operation family is: + +```text +split_node -> merge_node -> split_node -> merge_node +``` + +The generic Yjs collaboration example can hit the merge encoder error while +leaving an extra empty paragraph behind. + +## Stable browser evidence + +The following checks were run through the persistent Dev Browser on +`127.0.0.1:9222`, using the Fehala debug profile. + +### Generic Yjs collaboration example + +Route: + +```text +http://localhost:3100/examples/yjs-collaboration +``` + +Minimal single-peer reproduction: + +1. Open the route. +2. In Peer A, place the caret at the end of the first paragraph. +3. Press `Enter`. +4. Place the caret at the start of the new empty paragraph. +5. Press `Backspace`. +6. Place the caret at the end of the first paragraph again. +7. Press `Enter`. +8. Place the caret at the start of the new empty paragraph. +9. Press `Backspace`. + +Observed stability: + +```text +3/3 runs failed +``` + +Observed state after failure: + +```text +Peer A/B/C: + nested p count: 0 + element paths: 0, 1 + text: Hello world! +``` + +Observed browser error: + +```text +Cannot merge Yjs nodes of different kinds. +``` + +This route does not immediately render nested paragraphs, but the failed merge +leaves an extra empty paragraph. That is still a structural failure. + +## Root cause hypothesis + +The corruption appears to come from internal virtual Yjs placeholders being +treated as normal element children during later split/merge operations. + +Relevant source paths: + +```text +packages/slate-yjs/src/core/operations.ts +packages/slate-yjs/src/core/document.ts +playwright/integration/examples/yjs-collaboration.test.ts +``` + +Important code shape: + +1. `merge_node` for element + element uses `virtual-merge-ref`. + It inserts `createVirtualYjsMovePlaceholder(child)` into the previous node + and hides the merged target. + +2. `split_node` for elements reads raw `getYjsChildren(target)` and clones the + right-side children. + +3. `getYjsChildren` filters hidden nodes but does not filter virtual + placeholders. + +4. `readSlateNodeFromYjs` treats any remaining `Y.XmlElement` as a Slate + element. A leaked `slate-yjs-virtual-placeholder` can therefore become a + Slate element with empty `children`. + +5. Example renderers that map every Slate element to `

` can expose leaked + placeholders as nested paragraphs. + +The likely bad transition is: + +```text +First Enter: + split paragraph into [text paragraph, empty paragraph] + +First Backspace: + merge empty paragraph back through virtual-merge-ref + leave virtual placeholder state behind in the raw Yjs tree + +Second Enter: + split a paragraph whose raw Yjs children include a virtual placeholder + clone or carry the placeholder into the right-side split paragraph + +Second Backspace: + merge the right-side paragraph and virtualize a virtual placeholder + readSlateValueFromYjs leaks the internal placeholder as a normal Slate element +``` + +## Why existing tests missed it + +The provider Playwright test covers provider status, remote cursors, reconnect, +and append behavior. It does not cover real keyboard paragraph split/merge. + +The existing Yjs unit contracts cover individual `split_node` and `merge_node` +operation families, but they do not cover repeated split/merge cycles where a +previous virtual merge placeholder is still present in the raw Yjs tree. + +The missing regression shape is: + +```text +repeat twice: + select end of paragraph + insert break + select start of empty paragraph + delete backward + +assert: + no nested block DOM + getText() does not throw + no leaked slate-yjs-virtual-placeholder element in Slate value + no "Cannot merge Yjs nodes of different kinds" page error +``` + +## Suggested fix direction + +Treat virtual placeholders as internal Yjs bookkeeping, not normal document +children. + +Likely repair points: + +1. Element `split_node` should operate on visible Slate children or explicitly + handle virtual placeholders instead of blindly cloning `getYjsChildren`. +2. `merge_node` should not create a virtual move placeholder for an existing + virtual placeholder. +3. `readSlateValueFromYjs` should never expose + `slate-yjs-virtual-placeholder` as a Slate element. +4. Add provider and generic Yjs browser regressions for the two-loop + `Enter -> Backspace` sequence. + +## Classification + +Severity: high + +Reason: +- corrupts shared collaborative document state; +- syncs to remote peers; +- throws browser/runtime errors; +- makes ordinary text reads fail; +- can be triggered by normal keyboard editing without offline/reconnect. diff --git a/autoresearch.research/yjs-pr21/notes/yjs-collaboration-soak-findings-2026-06-03.md b/autoresearch.research/yjs-pr21/notes/yjs-collaboration-soak-findings-2026-06-03.md new file mode 100644 index 0000000000..7b5d5edffa --- /dev/null +++ b/autoresearch.research/yjs-pr21/notes/yjs-collaboration-soak-findings-2026-06-03.md @@ -0,0 +1,371 @@ +# Yjs Collaboration Bug Reproductions + +All reproductions target `/examples/yjs-collaboration`. +Run each step as a visible browser action and wait for the editor to settle after each action. + +## 1. Offline split undo plus remote split duplicates right text + +Steps: + +1. Peer A `Offline` +2. Peer A `Split` +3. Peer A `Undo` +4. Peer B `Split` +5. Peer A `Online` + +Expected: + +- All peers converge to `["Hello ", "world!"]`. + +Observed: + +- All peers converge to `["Hello world!", "world!"]`. + +Redo variant: + +1. Peer A `Offline` +2. Peer A `Split` +3. Peer A `Undo` +4. Peer B `Insert !` +5. Peer B `Split` +6. Peer A `Online` +7. Peer A `Redo` + +Expected: + +- All peers converge to `["Hello ", "world!!"]`. + +Observed: + +- All peers converge to `["Hello ", "world!", "world!!"]`. + +## 2. Structural mix crashes with a Slate/Yjs text-path mismatch + +Steps: + +1. Peer A `Offline` +2. Peer A `Unset Role` +3. Peer D `Lift` +4. Peer A `Down` +5. Peer D `Wrap` +6. Peer A `Down` +7. Peer B `Unwrap` + +Expected: + +- The editors remain mounted. +- All peers can reconnect and converge. + +Observed: + +- Page error: `Slate point does not target a Y.XmlText.` +- Console error: `Cannot get the leaf node at path [0,0] because it refers to a non-leaf node`. +- Editor root count becomes `0`. + +## 3. Structural edits produce nested paragraph DOM + +Steps: + +1. Peer A `Wrap` +2. Peer C `Append` +3. Peer A `Split` +4. Peer C `Down` +5. Peer C `Merge` + +Expected: + +- No paragraph is rendered inside another paragraph. + +Observed: + +- Console error: `In HTML,

cannot be a descendant of

.` +- Console error: `

cannot contain a nested

.` +- DOM evidence: + - outer paragraph: `data-slate-path="0"` + - nested paragraph: `data-slate-path="0,1"` + +## 4. Structural edits produce placeholder div inside paragraph + +Steps: + +1. Peer A `Split` +2. Peer C `Delete` +3. Peer C `Set Role` +4. Peer D `Reconcile` +5. Peer C `Redo` if enabled; otherwise skip +6. Peer D `Back` +7. Peer A `Online` +8. Peer C `Remove` + +Expected: + +- Empty-text placeholder rendering does not create invalid HTML under a paragraph. + +Observed: + +- Console error: `In HTML,

cannot be a descendant of

.` +- Console error: `

cannot contain a nested

.` +- The nested `div` is the Slate placeholder rendered for an empty text block inside a paragraph. + +## 5. Root can have no start text node + +Steps: + +1. Peer A `Offline` +2. Peer A `Merge` +3. Peer D `Split` +4. Peer A `Wrap` +5. Peer C `Split` +6. Peer A `Delete` +7. Peer C `Move` +8. Peer A `Wrap` +9. Peer D `Split` +10. Peer A `Online` + +Expected: + +- `Editor.point([], { edge: "start" })` can find a text node. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the start point in the node at path [] because it has no start text node.` +- The page loses all editor roots afterward. + +## 6. Root can have no end text node + +Steps: + +1. Peer B `Insert !` +2. Peer A `Back` +3. Peer B `Undo` +4. Peer C `Insert Fragment` +5. Peer C `Offline` +6. Peer D `Split` +7. Peer D `Unwrap` +8. Peer A `Move` +9. Peer A `Back` +10. Peer D `Merge` +11. Peer B `Redo` +12. Peer C `Unset Role` +13. Peer D `Delete` + +Expected: + +- `Editor.point([], { edge: "end" })` can find a text node. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the end point in the node at path [] because it has no end text node.` +- The next browser action can be blocked by the Next error overlay. + +## 7. Leaf path points to a paragraph after structural mix seed 42 + +Steps: + +1. Peer B `Offline` +2. Peer B `Wrap` +3. Peer C `Split` +4. Peer B `Down` + +Expected: + +- The leaf path used by selection/render reads points to a text leaf. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the leaf node at path [1,0] because it refers to a non-leaf node`. +- The non-leaf node is a paragraph containing `Hello world!`. +- Editor root count becomes `0`. + +## 8. Leaf path points to a paragraph after random control seed 42 + +Steps: + +1. Peer B `Insert !` +2. Peer C `Wrap` +3. Peer B `Append` +4. Peer B `Split` +5. Peer D `Reconcile` +6. Peer D `Move` +7. Peer C `Remove` +8. Peer C `Insert !` +9. Peer A `Online` +10. Peer D `Offline` +11. Peer C `Merge` +12. Peer C `Unwrap` +13. Peer D `Remove` + +Expected: + +- The leaf path used by selection/render reads points to a text leaf. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the leaf node at path [0,0] because it refers to a non-leaf node`. +- The non-leaf node is a paragraph containing `Hello wo`. +- Editor root count becomes `0`. + +## 9. Leaf path points to nested block structure after structural mix seed 43 + +Steps: + +1. Peer B `Offline` +2. Peer B `Insert Fragment` +3. Peer A `Split` +4. Peer B `Wrap` +5. Peer D `Lift` +6. Peer B `Wrap` +7. Peer C `Append` +8. Peer B `Down` + +Expected: + +- The leaf path used by selection/render reads points to a text leaf. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the leaf node at path [0]` or `[1]` because it refers to a non-leaf node. +- The non-leaf node is a nested block structure, including block quotes and paragraph text like `Hello world!Lin fragment`. +- Editor root count becomes `0`. + +## 10. Leaf path points to role title paragraph after structural mix seed 46 + +Steps: + +1. Peer B `Offline` +2. Peer B `Set Role` +3. Peer A `Merge` +4. Peer B `Wrap` +5. Peer A `Insert !` +6. Peer B `Merge` + +Expected: + +- The leaf path used by selection/render reads points to a text leaf. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the leaf node at path [0,0] because it refers to a non-leaf node`. +- The non-leaf node is a paragraph with `role: "title"`. +- Editor root count becomes `0`. + +## 11. Leaf path points to a paragraph after structural mix seed 49 + +Steps: + +1. Peer B `Offline` +2. Peer B `Merge` +3. Peer C `Move` +4. Peer B `Wrap` +5. Peer A `Move` +6. Peer B `Unset Role` + +Expected: + +- The leaf path used by selection/render reads points to a text leaf. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the leaf node at path [0,0] because it refers to a non-leaf node`. +- The non-leaf node is a paragraph. +- Editor root count becomes `0`. + +## 12. Leaf path points to a block quote after structural mix seed 55 + +Steps: + +1. Peer B `Offline` +2. Peer B `Wrap` +3. Peer A `Move` +4. Peer B `Wrap` +5. Peer A `Insert !` +6. Peer B `Merge` +7. Peer C `Merge` + +Expected: + +- The leaf path used by selection/render reads points to a text leaf. +- The editor remains mounted. + +Observed: + +- Console error: `Cannot get the leaf node at path [0,0] because it refers to a non-leaf node`. +- The non-leaf node is a `block-quote` containing a paragraph. +- Editor root count becomes `0`. + +## 13. Random structural edits lose a Yjs path lookup + +Steps: + +1. Peer B `Insert !` +2. Peer C `Wrap` +3. Peer B `Append` +4. Peer B `Split` +5. Peer D `Reconcile` +6. Peer D `Move` +7. Peer C `Remove` +8. Peer C `Insert !` +9. Peer A `Online` +10. Peer D `Offline` +11. Peer C `Merge` +12. Peer C `Unwrap` + +Expected: + +- Every Slate path produced by the structural edits maps to a Yjs node. + +Observed: + +- Page error: `No Yjs node at path 1.0`. +- The next browser action can be blocked by the Next error overlay. + +## 14. Yjs path lookup failure repeat from random seed 96 + +Steps: + +1. Peer B `Reconcile` +2. Peer C `Wrap` +3. Peer B `Delete` +4. Peer C `Set Role` +5. Peer B `Split` +6. Peer B `Move` +7. Peer B `Unwrap` + +Expected: + +- Every Slate path produced by the structural edits maps to a Yjs node. + +Observed: + +- Page error: `No Yjs node at path 1.0`. + +## 15. Yjs path lookup failure repeat from random seed 115 + +Steps: + +1. Peer B `Undo` if enabled; otherwise skip +2. Peer C `Online` +3. Peer A `Unset Role` +4. Peer D `Reconcile` +5. Peer A `Wrap` +6. Peer D `Move` +7. Peer D `Insert Fragment` +8. Peer B `Split` +9. Peer B `Insert !` +10. Peer C `Unset Role` +11. Peer D `Unwrap` + +Expected: + +- Every Slate path produced by the structural edits maps to a Yjs node. + +Observed: + +- Page error: `No Yjs node at path 1.0`. diff --git a/autoresearch.research/yjs-pr21/plan.md b/autoresearch.research/yjs-pr21/plan.md new file mode 100644 index 0000000000..ef146a2e73 --- /dev/null +++ b/autoresearch.research/yjs-pr21/plan.md @@ -0,0 +1,13 @@ +# Research Plan: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +## Workstreams +- Project essence and audience +- Current implementation and architecture evidence +- High-impact improvement candidates +- Risks, constraints, and validation strategy + +## Sequencing +- Gather evidence first. +- Synthesize findings into `synthesis.md`. +- Convert actionable findings into `quality-gaps.md`. +- Iterate with the Codex Autoresearch skill until `quality_gap=0`. diff --git a/autoresearch.research/yjs-pr21/quality-gaps.md b/autoresearch.research/yjs-pr21/quality-gaps.md new file mode 100644 index 0000000000..1b96a5e45e --- /dev/null +++ b/autoresearch.research/yjs-pr21/quality-gaps.md @@ -0,0 +1,25 @@ +# Quality Gaps: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +- [x] Project essence is accurate and source-backed. +- [x] Sources are logged with dates, claims, and confidence. +- [x] Synthesis separates high-impact changes from small QoL fixes. +- [x] Each high-impact recommendation is routed or rejected with evidence. +- [x] Correctness checks are scoped for this research-only pass. +- [x] Final handoff includes dashboard or state evidence. + +## Accepted Routed Gaps + +| Gap | Route | Evidence | Validation | +| --- | --- | --- | --- | +| Provider lifecycle and connection semantics are too implicit for public DX. | `slate-plan` | `YjsExtensionOptions` accepts caller-owned `doc` and awareness; `tx.yjs.connect()` / `disconnect()` only flip local state. Lexical's Yjs provider contract names `connect`, `disconnect`, `sync`, `status`, and `reload`. | Plan the public provider/lifecycle shape: ownership, status/sync/reload semantics, cleanup, reconnect, docs, and migration-free example usage. | +| Remote cursor rendering is underpowered. | `slate-plan` | Current `@slate/yjs/react` exposes `useYjsRemoteCursor(s)` and awareness revision. Older `slate-yjs` ships decoration/overlay hooks; `y-prosemirror` ships a cursor plugin with builders, filtering, focus cleanup, and selection decorations. | Plan first-party React cursor decoration/rendering APIs with tests for caret, range, user data, local-user filtering, stale/blur cleanup, custom field names, and virtual moved-node identity. | +| Operation encoder exhaustiveness is not explicit. | `slate-patch` | `Operation` has a closed current union, and `applySlateOperationToYjs` handles current cases but has no visible exhaustive `never` guard. Future Slate operation kinds could silently escape the switch if compiler settings miss them. | Add failing-first contract coverage, then an exhaustive guard that fails when Slate adds an operation without a Yjs decision. | +| Collaboration proof lacks a named release gate. | `slate-ar-gate` | Package contracts and the Yjs browser suite cover reconnect, undo/redo, awareness, selection, stale undo, and operation families, but the route is not captured as one release-quality gate. | Define and run a focused gate bundle: `bun test ./packages/slate-yjs/test`, `bun --filter @slate/yjs typecheck`, and focused Playwright Yjs collaboration greps for reconnect, undo/redo, awareness, selection, and stale undo. | + +## Rejected Candidates + +| Candidate | Decision | Evidence | +| --- | --- | --- | +| Basic operation/test coverage is missing. | rejected | Current package contracts cover every current operation family plus awareness, selection, reconnect, undo/redo, and fallback traces. | +| Immediate UndoManager private-stack rewrite. | rejected for this round | Private stack usage is isolated in `undo-manager-adapter`, pinned to Yjs 13.6.30, and covered by contract tests. | +| `slate-ar-perf` route. | rejected for this round | A Yjs collaboration benchmark exists, but this pass found no measured perf regression or threshold miss. Route to perf only after a concrete metric gap appears. | diff --git a/autoresearch.research/yjs-pr21/sources.md b/autoresearch.research/yjs-pr21/sources.md new file mode 100644 index 0000000000..85832db849 --- /dev/null +++ b/autoresearch.research/yjs-pr21/sources.md @@ -0,0 +1,16 @@ +# Research Sources: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +| Source | Date Checked | Claim Supported | Confidence | +| --- | --- | --- | --- | +| `packages/slate-yjs/package.json` | 2026-06-02 | `@slate/yjs` publishes root, `./core`, `./internal`, and `./react` entrypoints; it depends on Yjs 13.6.30 and peer-depends on Slate/React/Yjs. | high | +| `packages/slate-yjs/src/core/types.ts` | 2026-06-02 | Public options accept `doc`, `awareness`, selection/data field names, `clientId`, `rootName`, and `autoSendSelection`; state/tx APIs expose cursors, awareness revision, connect/disconnect, pause/resume, reconcile, undo/redo, and selection sending. | high | +| `packages/slate-yjs/src/core/controller.ts` | 2026-06-02 | The runtime owns seed/import, local-operation export, Yjs UndoManager wiring, awareness subscription, local connected/paused flags, remote cursor reads, split-history replay, and selection sanitization. `connect`/`disconnect` currently flip local state only. | high | +| `packages/slate-yjs/src/core/operations.ts` | 2026-06-02 | The encoder handles current Slate operation kinds, including traceable fallbacks for virtual move/merge/unwrap/replace cases, but lacks an explicit exhaustive `never` guard for future Slate operation additions. | high | +| `packages/slate-yjs/src/react/index.ts` | 2026-06-02 | React support exposes awareness revision and remote cursor hooks, but no first-party decorate/render/overlay helpers. | high | +| `packages/slate-yjs/test/*contract.spec.ts` | 2026-06-02 | Package contracts cover awareness, selection, basic operations, set/remove/move/merge/split/wrap/unwrap/lift/fragments, reconnect, undo/redo, and fallback traces. | high | +| `playwright/integration/examples/yjs-collaboration.test.ts` | 2026-06-02 | Browser proof covers the operation matrix, awareness selection, disconnect/reconnect, undo/redo, stale undo, offline mark/replace, and selection/focus behavior, but the proof is not named as one release gate. | high | +| `site/examples/ts/yjs-collaboration.tsx` | 2026-06-02 | The public example is a deterministic four-peer proof harness with a hand-rolled `ExampleNetwork` and fake awareness, not a minimal provider-backed copy-paste example. | high | +| `scripts/benchmarks/core/current/yjs-collaboration.mjs` | 2026-06-02 | A Yjs collaboration benchmark exists for multi-editor sync, awareness, reconnect, and large-doc sync, but this round found no source-backed perf regression requiring `slate-ar-perf`. | medium | +| `../lexical/packages/lexical-yjs/src/index.ts` | 2026-06-02 | Lexical's Yjs binding models provider lifecycle/status/reload/sync events explicitly in its provider contract. | medium | +| `../slate-yjs/packages/react/src/hooks/useDecorateRemoteCursors.ts` and `useRemoteCursorOverlayPositions.tsx` | 2026-06-02 | The older `slate-yjs` React layer includes remote cursor decoration and overlay helpers, which are missing from current `@slate/yjs/react`. | medium | +| `../y-prosemirror/src/cursor-plugin.js` | 2026-06-02 | y-prosemirror ships a first-party cursor plugin with awareness filtering, cursor/selection builders, focus cleanup, and rendered decorations. | medium | diff --git a/autoresearch.research/yjs-pr21/synthesis.md b/autoresearch.research/yjs-pr21/synthesis.md new file mode 100644 index 0000000000..624959e185 --- /dev/null +++ b/autoresearch.research/yjs-pr21/synthesis.md @@ -0,0 +1,25 @@ +# Research Synthesis: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +## Project Essence +`@slate/yjs` is the first-party Slate v2 Yjs binding. The current package is no longer a stub: it has core/internal/react entrypoints, a real controller, operation-family contract tests, browser proof, and a collaboration benchmark. The right next work is not "add basic coverage." That claim is stale. + +The best target is a release-quality collaboration surface: provider lifecycle that is obvious, cursor rendering that an app can ship without reverse-engineering awareness ranges, operation encoding that fails loudly when Slate grows, and proof gates that make offline/reconnect/undo/redo/awareness/selection regressions hard to sneak through. + +## High-Impact Findings +- Provider lifecycle is too implicit for public DX. `createYjsExtension` accepts a `Y.Doc` and awareness-like object, while `tx.yjs.connect()` and `disconnect()` only flip local package state. Lexical's provider type names `connect`, `disconnect`, `sync`, `status`, and `reload`; `@slate/yjs` needs an intentional public shape before users build their own half-correct lifecycle wrappers. Route: `slate-plan`. +- Remote cursor rendering is underpowered. Current React exports are useful state hooks, but apps still need to build decoration, caret, range rendering, overlay positioning, local-user filtering, and blur cleanup. Older `slate-yjs` and `y-prosemirror` both provide first-party cursor rendering/decorator surfaces. Route: `slate-plan`. +- Operation encoding should be future-proofed. `applySlateOperationToYjs` covers the current Slate operation union, but there is no explicit exhaustive guard. If Slate adds an operation, TypeScript can let this function fall off the switch unless compiler settings catch it. Route: `slate-patch`. +- Proof exists but is scattered. Package tests and the Yjs browser suite cover the important behavior families, yet there is no single named release gate for "Yjs collaboration is safe." Route: `slate-ar-gate`. +- A collaboration benchmark exists, but this quality pass did not find a current perf failure, threshold miss, or measured regression. Route: reject `slate-ar-perf` for this round. + +## Quality-Gap Translation +- Accepted gaps should become downstream lane inputs, not direct edits from this research pass. +- Reject generic "missing operation coverage" claims because the package tests already cover each current operation family. +- Reject immediate UndoManager-private-stack churn because the private access is isolated, version-pinned, and contract-tested. +- Reject perf routing until a measured Yjs collaboration regression is named. + +## Confidence And Gaps +- Confidence is high for the current package/test/example shape because it comes from live local source inspection. +- Confidence is medium for comparison repos because they are local snapshots and may not represent latest upstream, but they are enough to show common collaboration DX surfaces. +- Known unknown: the exact provider API should be designed in `slate-plan`, not guessed inside this quality-gap pass. +- Known unknown: the release gate command should be tuned in `slate-ar-gate` after measuring runtime and flake risk. diff --git a/autoresearch.research/yjs-pr21/tasks.md b/autoresearch.research/yjs-pr21/tasks.md new file mode 100644 index 0000000000..b1e4fde21d --- /dev/null +++ b/autoresearch.research/yjs-pr21/tasks.md @@ -0,0 +1,21 @@ +# Research Tasks: Perfect @slate/yjs collaboration API, DX, correctness, offline/reconnect, undo/redo, awareness, selection, examples, and test coverage; route each accepted gap to slate-patch, slate-plan, slate-ar-gate, slate-ar-perf, or slate-ar. + +## queued +- None. + +## in_progress +- None. + +## done +- Scratchpad initialized. +- Captured project essence from live repo evidence. +- Logged primary sources and local comparison evidence. +- Converted recommendations into routed quality gaps. +- Rejected stale or weak candidates. +- Routed accepted gaps to `slate-plan`, `slate-patch`, and `slate-ar-gate`. +- Rejected `slate-ar-perf` for this round because no measured perf gap was found. +- Verified `quality_gap=0` for `yjs-pr21`. +- Fixed stale `autoresearch.sh` / `autoresearch.md` references from `yjs-pr20` and plugin `2.0.1`. + +## blockers +- None. diff --git a/autoresearch.sh b/autoresearch.sh index 15779d5e74..ea1863a7ed 100755 --- a/autoresearch.sh +++ b/autoresearch.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail -"/Users/felixfeng/.nvm/versions/node/v24.11.1/bin/node" "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.1/scripts/autoresearch.mjs" quality-gap --cwd . --research-slug "yjs-pr20" +"/Users/felixfeng/.nvm/versions/node/v24.11.1/bin/node" "/Users/felixfeng/.codex/plugins/cache/thegreencedar-autoresearch/codex-autoresearch/2.0.2/scripts/autoresearch.mjs" quality-gap --cwd . --research-slug "yjs-pr21" From 04531ea286ab48dcbca9646199c8f8b5dde3b474 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Tue, 9 Jun 2026 17:03:59 +0800 Subject: [PATCH 06/11] Harden Yjs collaboration stability --- .changeset/fresh-ranges-bow.md | 5 + .changeset/silent-placeholders-smile.md | 5 + .changeset/steady-yjs-structures.md | 5 + .../2026-05-27-yjs-collaboration-soak.md | 19 +- package.json | 1 + packages/slate-dom/src/plugin/dom-editor.ts | 22 +- packages/slate-dom/test/bridge.ts | 61 ++ .../src/components/slate-placeholder.tsx | 2 +- .../slate-react/test/primitives-contract.tsx | 9 + packages/slate-yjs/src/core/controller.ts | 458 ++---------- packages/slate-yjs/src/core/history.ts | 105 +++ packages/slate-yjs/src/core/operations.ts | 42 +- packages/slate-yjs/src/core/provider.ts | 82 +++ packages/slate-yjs/src/core/split-history.ts | 212 ++++++ .../test/merge-node-contract.spec.ts | 133 +--- .../test/remove-node-contract.spec.ts | 107 +-- .../test/replace-fragment-contract.spec.ts | 140 +--- .../test/split-node-contract.spec.ts | 134 +--- .../test/structural-soak-contract.spec.ts | 297 ++++++++ .../slate-yjs/test/support/collaboration.ts | 20 +- .../examples/yjs-collaboration.test.ts | 243 +++++++ scripts/proof/yjs-collaboration-soak.mjs | 684 ++++++++++++++++++ tmp/yjs-collaboration-soak.mjs | 9 + 23 files changed, 1963 insertions(+), 832 deletions(-) create mode 100644 .changeset/fresh-ranges-bow.md create mode 100644 .changeset/silent-placeholders-smile.md create mode 100644 .changeset/steady-yjs-structures.md create mode 100644 packages/slate-yjs/src/core/history.ts create mode 100644 packages/slate-yjs/src/core/provider.ts create mode 100644 packages/slate-yjs/src/core/split-history.ts create mode 100644 scripts/proof/yjs-collaboration-soak.mjs create mode 100644 tmp/yjs-collaboration-soak.mjs diff --git a/.changeset/fresh-ranges-bow.md b/.changeset/fresh-ranges-bow.md new file mode 100644 index 0000000000..1461e72629 --- /dev/null +++ b/.changeset/fresh-ranges-bow.md @@ -0,0 +1,5 @@ +--- +"slate-dom": patch +--- + +Fix DOM selection resolution after structural edits with stale rendered paths. diff --git a/.changeset/silent-placeholders-smile.md b/.changeset/silent-placeholders-smile.md new file mode 100644 index 0000000000..083bb0e3a2 --- /dev/null +++ b/.changeset/silent-placeholders-smile.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Render default placeholders inline so empty paragraphs keep valid HTML. diff --git a/.changeset/steady-yjs-structures.md b/.changeset/steady-yjs-structures.md new file mode 100644 index 0000000000..3e23f2d45a --- /dev/null +++ b/.changeset/steady-yjs-structures.md @@ -0,0 +1,5 @@ +--- +"@slate/yjs": patch +--- + +Fix structural collaboration edits that could crash or render nested paragraphs. diff --git a/docs/plans/2026-05-27-yjs-collaboration-soak.md b/docs/plans/2026-05-27-yjs-collaboration-soak.md index 31773bcf5d..d68caf21e3 100644 --- a/docs/plans/2026-05-27-yjs-collaboration-soak.md +++ b/docs/plans/2026-05-27-yjs-collaboration-soak.md @@ -2,15 +2,17 @@ ## Goal -Use `dev-browser` against the local `yjs-collaboration` example to simulate -normal multi-user collaborative editing for about two hours. Record runtime -errors, convergence failures, stale presence, and suspicious user-visible state. +Use the formal Yjs collaboration soak runner against the local +`yjs-collaboration` example to simulate normal multi-user collaborative +editing. Record runtime errors, convergence failures, stale presence, and +suspicious user-visible state. ## Scope -- Browser: persistent debug Chrome at `http://127.0.0.1:9222`. +- Browser: Playwright-launched Chromium by default, or persistent debug Chrome + when `SOAK_LAUNCH=0`. - Target: `http://127.0.0.1:3100/examples/yjs-collaboration`. -- Duration: about 2 hours. +- Duration: defaults to 3 hours; override with `SOAK_MS`. - Frequency: low-frequency edits so this resembles human collaboration rather than a stress fuzzer. - No code fixes in this pass. @@ -26,9 +28,10 @@ errors, convergence failures, stale presence, and suspicious user-visible state. ## Recording -- Harness script: `.tmp/yjs-collab-soak/soak-runner.mjs` -- Log: `.tmp/yjs-collab-soak/soak.log` -- Summary: `.tmp/yjs-collab-soak/summary.json` +- Command: `bun test:yjs-collaboration-soak` +- Harness script: `scripts/proof/yjs-collaboration-soak.mjs` +- Log: `test-results/yjs-collaboration-soak//events.jsonl` +- Summary: `test-results/yjs-collaboration-soak//summary.md` ## Status diff --git a/package.json b/package.json index 98b5b90482..dff0a2d7d4 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "test:mobile-device-proof": "bun ./scripts/proof/mobile-device-proof.mjs", "test:mobile-device-proof:raw": "SLATE_BROWSER_RAW_MOBILE_REQUIRED=1 bun ./scripts/proof/mobile-device-proof.mjs", "test:persistent-soak": "bun build:next && bun ./scripts/proof/persistent-browser-soak.mjs", + "test:yjs-collaboration-soak": "bun ./scripts/proof/yjs-collaboration-soak.mjs", "test:release-discipline": "bun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/public-field-hard-cut-contract.ts ./packages/slate/test/escape-hatch-inventory-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/leaf-lifecycle-contract.ts ./packages/slate/test/selection-rebase-contract.ts ./packages/slate/test/compat-alias-hard-cut-contract.ts ./packages/slate/test/migration-backbone-contract.ts ./packages/slate/test/core-benchmark-scripts-contract.ts ./packages/slate/test/release-scripts-contract.ts ./packages/slate-react/test/rendered-dom-shape-contract.tsx --bail 1", "test:release-proof": "bun test:release-discipline && bun --filter slate-browser test:proof && bun test:mobile-device-proof && bun test:persistent-soak", "test:slate-browser": "bun --filter slate-browser test", diff --git a/packages/slate-dom/src/plugin/dom-editor.ts b/packages/slate-dom/src/plugin/dom-editor.ts index 1d5ae5df97..51734f60f5 100644 --- a/packages/slate-dom/src/plugin/dom-editor.ts +++ b/packages/slate-dom/src/plugin/dom-editor.ts @@ -475,6 +475,12 @@ const resolveMountedDOMPath = ( editor: DOMEditor, element: HTMLElement ): Path | null => { + const runtimePath = getSlateDOMRuntimePath(editor, element) + + if (runtimePath && Editor.hasPath(editor, runtimePath)) { + return runtimePath + } + const attributePath = parseSlateDOMPath( element.getAttribute('data-slate-path') ) @@ -483,9 +489,7 @@ const resolveMountedDOMPath = ( return attributePath } - const runtimePath = getSlateDOMRuntimePath(editor, element) - - return runtimePath && Editor.hasPath(editor, runtimePath) ? runtimePath : null + return null } const findMountedDOMNodeByPath = ( @@ -615,18 +619,26 @@ const resolveSlatePointFromDOMCoverageBoundary = ( } const resolveSlateTextPoint = ({ + editor, exactMatch, offset, path, slateNode, }: { + editor: DOMEditor exactMatch: boolean offset: number path: Path slateNode: Node }): Point | null => { if (!TextApi.isText(slateNode)) { - return { path, offset } + if (!Editor.hasPath(editor, path)) { + return null + } + + return Editor.point(editor, path, { + edge: offset <= 0 ? 'start' : 'end', + }) } const textLength = slateNode.text.length @@ -1848,6 +1860,7 @@ export const DOMEditor: DOMEditorInterface = { state.nodes.get(fallbackPath) ) const point = resolveSlateTextPoint({ + editor, exactMatch, offset, path: fallbackPath, @@ -1861,6 +1874,7 @@ export const DOMEditor: DOMEditorInterface = { } const point = resolveSlateTextPoint({ + editor, exactMatch, offset, path, diff --git a/packages/slate-dom/test/bridge.ts b/packages/slate-dom/test/bridge.ts index 0d39431d0b..9687de85d8 100644 --- a/packages/slate-dom/test/bridge.ts +++ b/packages/slate-dom/test/bridge.ts @@ -270,6 +270,67 @@ describe('slate-dom bridge', () => { }) }) + it('resolves Slate points by runtime id before stale mounted DOM paths', () => { + withDom(({ document }) => { + const editor = createEditor({ extensions: [dom()] }) + + Editor.replace(editor, { + children: [ + { type: 'paragraph', children: [{ text: 'first' }] }, + { type: 'paragraph', children: [{ text: 'target' }] }, + ] satisfies Descendant[], + }) + seedNodeMaps( + editor, + editor.read((state) => + state.runtime + .snapshot() + .children.map( + (_, index) => state.nodes.get([index])[0] as Descendant + ) + ) + ) + + const root = mountEditorRoot(editor, document) + const owner = document.createElement('span') + const leaf = document.createElement('span') + const string = document.createElement('span') + const domText = document.createTextNode('target') + const targetRuntimeId = Editor.getRuntimeId(editor, [1, 0]) + + expect(targetRuntimeId).toBeTruthy() + owner.setAttribute('data-slate-node', 'text') + owner.setAttribute('data-slate-path', '0,0') + owner.setAttribute('data-slate-runtime-id', targetRuntimeId!) + leaf.setAttribute('data-slate-leaf', 'true') + string.setAttribute('data-slate-string', 'true') + + string.appendChild(domText) + leaf.appendChild(string) + owner.appendChild(leaf) + root.appendChild(owner) + + editor.update((tx) => { + tx.nodes.insert( + { type: 'paragraph', children: [{ text: 'inserted' }] } as never, + { at: [0] } + ) + }) + + expect(Editor.getPathByRuntimeId(editor, targetRuntimeId!)).toEqual([ + 2, 0, + ]) + expect( + editor.api.dom.assertSlatePoint([domText, 3], { + exactMatch: false, + }) + ).toEqual({ + path: [2, 0], + offset: 3, + }) + }) + }) + it('does not resolve path-tagged DOM nodes outside the editor', () => { withDom(({ document }) => { const editor = createParagraphEditor() diff --git a/packages/slate-react/src/components/slate-placeholder.tsx b/packages/slate-react/src/components/slate-placeholder.tsx index 8e22120e0b..191147aaa6 100644 --- a/packages/slate-react/src/components/slate-placeholder.tsx +++ b/packages/slate-react/src/components/slate-placeholder.tsx @@ -61,7 +61,7 @@ export const getSlatePlaceholderStyle = ( }) export const SlatePlaceholder = ({ - as = 'div', + as = 'span', children, dir, ref, diff --git a/packages/slate-react/test/primitives-contract.tsx b/packages/slate-react/test/primitives-contract.tsx index 3858ebb174..0d00b96d5a 100644 --- a/packages/slate-react/test/primitives-contract.tsx +++ b/packages/slate-react/test/primitives-contract.tsx @@ -81,6 +81,15 @@ describe('slate-react primitives contract', () => { expect(placeholder?.getAttribute('data-slate-placeholder')).toBe('true') }) + test('SlatePlaceholder defaults to an inline-safe span', () => { + const rendered = render(placeholder) + const placeholder = rendered.container.querySelector( + '[data-slate-placeholder="true"]' + ) + + expect(placeholder?.tagName).toBe('SPAN') + }) + test('EditableText passes overlay defaults to custom placeholder renderers', () => { const rendered = render( - rightText: string - textPath: Path - textProperties: Record - undoneWhileDisconnected?: boolean -} - -type PendingTextSplitHistory = Omit< - SplitHistory, - 'elementPosition' | 'elementProperties' -> - -type HistoryBatchLike = { - operations?: Operation[] - statePatches?: unknown[] -} - -type HistoryLike = { - redos?: HistoryBatchLike[] - undos?: HistoryBatchLike[] -} - -const SPLIT_HISTORY_META = 'slate-yjs:split-history' - -const operationsEqual = (a: Operation, b: Operation | undefined) => - !!b && JSON.stringify(a) === JSON.stringify(b) - -const normalizeProviderStatus = ( - value: YjsProviderStatusPayload | unknown -): YjsProviderStatus | null => { - if (typeof value === 'string') { - return value - } - - if ( - value && - typeof value === 'object' && - 'status' in value && - typeof value.status === 'string' - ) { - return value.status - } - - return null -} - -const normalizeProviderSynced = ( - value: YjsProviderSyncedPayload | unknown -): boolean | null => { - if (typeof value === 'boolean') { - return value - } - - if ( - value && - typeof value === 'object' && - 'state' in value && - typeof value.state === 'boolean' - ) { - return value.state - } - - if ( - value && - typeof value === 'object' && - 'synced' in value && - typeof value.synced === 'boolean' - ) { - return value.synced - } - - return null -} - -const readProviderStatus = (provider: YjsProviderLike | undefined) => - normalizeProviderStatus(provider?.status) - -const readProviderSynced = (provider: YjsProviderLike | undefined) => - normalizeProviderSynced(provider?.synced) - -const isPromiseLike = (value: unknown): value is PromiseLike => - Boolean( - value && - (typeof value === 'object' || typeof value === 'function') && - 'then' in value && - typeof value.then === 'function' - ) - const remoteImportOptions = { metadata: { collab: { origin: 'remote', saveToHistory: false }, @@ -206,9 +136,9 @@ export class YjsController { this.awarenessSelectionField = options.awarenessSelectionField ?? 'selection' this.autoSendSelection = options.autoSendSelection ?? true - this.providerStatusValue = readProviderStatus(this.provider) - this.providerSyncedValue = readProviderSynced(this.provider) - this.connected = this.connectedFromProviderStatus( + this.providerStatusValue = readYjsProviderStatus(this.provider) + this.providerSyncedValue = readYjsProviderSynced(this.provider) + this.connected = connectedFromYjsProviderStatus( this.providerStatusValue, this.connected ) @@ -216,7 +146,7 @@ export class YjsController { this.updateAwarenessRevision() } this.providerStatusObserver = (payload) => { - const status = normalizeProviderStatus(payload) + const status = normalizeYjsProviderStatus(payload) if (status) { this.updateProviderStatus(status) @@ -224,7 +154,8 @@ export class YjsController { } this.providerSyncedObserver = (payload) => { const synced = - normalizeProviderSynced(payload) ?? readProviderSynced(this.provider) + normalizeYjsProviderSynced(payload) ?? + readYjsProviderSynced(this.provider) if (synced !== null) { this.updateProviderSynced(synced) @@ -313,13 +244,13 @@ export class YjsController { } if (this.shouldRejectUnsafeProviderCommit()) { - this.removeRejectedOperationsFromHistory(operations) + removeRejectedYjsOperationsFromHistory(this.editor, operations) this.replaceEditorValue( this.readChildrenBeforeOperations(operations), commit.selectionBefore as Range | null ) - this.removeRejectedOperationsFromHistory(operations) - this.removeRejectedOperationsFromHistoryAfterCommit(operations) + removeRejectedYjsOperationsFromHistory(this.editor, operations) + removeRejectedYjsOperationsFromHistoryAfterCommit(this.editor, operations) return } @@ -328,16 +259,36 @@ export class YjsController { } const splitHistory = this.createSplitHistory(operations) + const rejectedLocalOperations: Operation[] = [] this.undoManager.stopCapturing() this.doc.transact(() => { for (const operation of operations) { - this.applyOperation(operation) + const trace = this.applyOperation(operation) + + if (this.shouldImportAfterLocalFallback(trace)) { + rejectedLocalOperations.push(operation) + } } }, this.localOrigin) this.storeSplitHistory(splitHistory) this.undoManager.stopCapturing() + if (rejectedLocalOperations.length > 0) { + this.replaceEditorValue( + readSlateValueFromYjs(this.root), + snapshot.selection + ) + removeRejectedYjsOperationsFromHistory( + this.editor, + rejectedLocalOperations + ) + removeRejectedYjsOperationsFromHistoryAfterCommit( + this.editor, + rejectedLocalOperations + ) + } + if (shouldSendSelection) { this.sendSelection(snapshot.selection) } @@ -463,28 +414,13 @@ export class YjsController { } private updateConnectedFromProviderStatus(status: YjsProviderStatus) { - const connected = this.connectedFromProviderStatus(status, this.connected) + const connected = connectedFromYjsProviderStatus(status, this.connected) this.setConnected(connected) } - private connectedFromProviderStatus( - status: YjsProviderStatus | null, - fallback: boolean - ) { - if (status === 'connected') { - return true - } - - if (status === 'connecting' || status === 'disconnected') { - return false - } - - return fallback - } - private syncProviderLifecycleStatus(fallbackConnected: boolean) { - const status = readProviderStatus(this.provider) + const status = readYjsProviderStatus(this.provider) if (status) { if (!fallbackConnected && status === 'connected') { @@ -590,89 +526,6 @@ export class YjsController { this.importFromYjs() } - private removeRejectedOperationsFromHistory( - operations: readonly Operation[] - ) { - const history = this.readHistory() - - if (!history) { - return - } - - this.removeRejectedOperationsFromHistoryStack(history.undos, operations) - this.removeRejectedOperationsFromHistoryStack(history.redos, operations) - } - - private removeRejectedOperationsFromHistoryAfterCommit( - operations: readonly Operation[] - ) { - const remove = () => { - this.removeRejectedOperationsFromHistory(operations) - } - - if (typeof queueMicrotask === 'function') { - queueMicrotask(remove) - } else { - void Promise.resolve().then(remove) - } - } - - private readHistory(): HistoryLike | null { - return this.editor.read((state) => { - const history = (state as any).history - - if (!history) { - return null - } - - return { - redos: history.redos?.(), - undos: history.undos?.(), - } - }) - } - - private removeRejectedOperationsFromHistoryStack( - stack: HistoryBatchLike[] | undefined, - operations: readonly Operation[] - ) { - if (!stack || operations.length === 0) { - return - } - - for (let batchIndex = stack.length - 1; batchIndex >= 0; batchIndex -= 1) { - const batch = stack[batchIndex] - const batchOperations = batch?.operations - - if (!Array.isArray(batchOperations)) { - throw new Error('Cannot remove rejected Yjs operations from history.') - } - - if (batchOperations.length < operations.length) { - continue - } - - const start = batchOperations.length - operations.length - - if ( - operations.every((operation, index) => - operationsEqual(operation, batchOperations[start + index]) - ) - ) { - batchOperations.splice(start, operations.length) - - if ( - batchOperations.length === 0 && - (batch.statePatches?.length ?? 0) === 0 - ) { - stack.splice(batchIndex, 1) - } - - return - } - } - } - private shouldDeferProviderSeed() { return ( this.providerOwnedDoc && @@ -889,7 +742,7 @@ export class YjsController { const trace = applySlateOperationToYjs(this.root, operation) if (!trace) { - return + return null } this.traceEntries.push(trace) @@ -897,6 +750,15 @@ export class YjsController { if (trace.mode === 'unsupported') { throw new Error(`Unsupported Yjs operation: ${operation.type}`) } + + return trace + } + + private shouldImportAfterLocalFallback(trace: YjsTraceEntry | null) { + return ( + trace?.mode === 'traceable-fallback' && + trace.fallback === 'incompatible-structural-merge-elided' + ) } private createSplitHistory( @@ -1249,187 +1111,3 @@ export class YjsController { } } } - -const appendTextContent = ( - target: Y.XmlText, - source: Y.XmlText, - extraAttributes: Record = {} -) => { - let offset = getYjsLength(target) - let insertedText = '' - - for (const delta of source.toDelta()) { - if (typeof delta.insert !== 'string' || delta.insert.length === 0) { - continue - } - - target.insert(offset, delta.insert, { - ...(delta.attributes ?? {}), - ...extraAttributes, - }) - offset += delta.insert.length - insertedText += delta.insert - } - - return insertedText -} - -const appendElementText = ( - root: Y.XmlElement, - target: Y.XmlText, - element: Y.XmlElement, - extraAttributes: Record = {} -) => { - let insertedText = '' - - for (const child of getYjsVisibleChildren(root, element)) { - if (child instanceof Y.XmlText) { - insertedText += appendTextContent(target, child, extraAttributes) - } else { - insertedText += appendElementText(root, target, child, extraAttributes) - } - } - - return insertedText -} - -const findLastVisibleText = ( - root: Y.XmlElement, - node: Y.XmlElement | Y.XmlText -): Y.XmlText | null => { - if (node instanceof Y.XmlText) { - return node - } - - const children = getYjsVisibleChildren(root, node) - - for (let index = children.length - 1; index >= 0; index--) { - const child = children[index] - const text = child ? findLastVisibleText(root, child) : null - - if (text) { - return text - } - } - - return null -} - -const getTrailingSplitUndoText = (text: Y.XmlText) => { - let offset = getYjsLength(text) - let value = '' - - for (const delta of [...text.toDelta()].reverse()) { - if (typeof delta.insert !== 'string' || delta.insert.length === 0) { - return value ? { length: value.length, offset, value } : null - } - - if (delta.attributes?.[SPLIT_UNDO_TEXT_ATTRIBUTE] === true) { - offset -= delta.insert.length - value = delta.insert + value - continue - } - - break - } - - return value ? { length: value.length, offset, value } : null -} - -const clearSplitUndoTextAttribute = ( - text: Y.XmlText, - offset: number, - length: number -) => { - text.format(offset, length, { - [SPLIT_UNDO_TEXT_ATTRIBUTE]: null, - } as unknown as Record) -} - -const getVisibleText = ( - root: Y.XmlElement, - node: Y.XmlElement | Y.XmlText -): string => { - if (node instanceof Y.XmlText) { - return getYjsTextContent(node) - } - - return getYjsVisibleChildren(root, node) - .map((child) => getVisibleText(root, child)) - .join('') -} - -const findSplitUndoTextRepairs = (root: Y.XmlElement) => { - const repairs: Array<{ - hasRemoteSplitBoundary: boolean - length: number - offset: number - text: Y.XmlText - }> = [] - - const visit = (parent: Y.XmlElement) => { - const children = getYjsVisibleChildren(root, parent) - - for (let index = 0; index < children.length; index++) { - const left = children[index] - - if (!(left instanceof Y.XmlElement)) { - continue - } - - const leftText = findLastVisibleText(root, left) - const right = children[index + 1] - const trailing = leftText ? getTrailingSplitUndoText(leftText) : null - - if (leftText && trailing) { - repairs.push({ - hasRemoteSplitBoundary: right - ? getVisibleText(root, right).startsWith(trailing.value) - : false, - length: trailing.length, - offset: trailing.offset, - text: leftText, - }) - } - } - - for (const child of children) { - if (child instanceof Y.XmlElement) { - visit(child) - } - } - } - - visit(root) - - return repairs -} - -const isSplitHistory = (value: unknown): value is SplitHistory => - typeof value === 'object' && - value !== null && - Array.isArray((value as SplitHistory).elementPath) && - Array.isArray((value as SplitHistory).textPath) && - typeof (value as SplitHistory).rightText === 'string' && - typeof (value as SplitHistory).elementPosition === 'number' - -const nextPath = (path: Path) => { - const index = path.at(-1) - - if (index === undefined) { - throw new Error('Cannot get a next path for the root.') - } - - return [...path.slice(0, -1), index + 1] -} - -const getYjsNodeIf = (root: Y.XmlElement, path: Path) => { - try { - return getYjsNode(root, path) - } catch { - return null - } -} - -const pathsEqual = (a: Path, b: Path) => - a.length === b.length && a.every((part, index) => part === b[index]) diff --git a/packages/slate-yjs/src/core/history.ts b/packages/slate-yjs/src/core/history.ts new file mode 100644 index 0000000000..a3a3666478 --- /dev/null +++ b/packages/slate-yjs/src/core/history.ts @@ -0,0 +1,105 @@ +import type { Editor, Operation } from 'slate' + +type HistoryBatchLike = { + operations?: Operation[] + statePatches?: unknown[] +} + +type HistoryLike = { + redos?: HistoryBatchLike[] + undos?: HistoryBatchLike[] +} + +type HistoryStateView = { + history?: { + redos?: () => HistoryBatchLike[] + undos?: () => HistoryBatchLike[] + } +} + +const operationsEqual = (a: Operation, b: Operation | undefined) => + !!b && JSON.stringify(a) === JSON.stringify(b) + +const readEditorHistory = (editor: Editor): HistoryLike | null => + editor.read((state) => { + const history = (state as HistoryStateView).history + + if (!history) { + return null + } + + return { + redos: history.redos?.(), + undos: history.undos?.(), + } + }) + +const removeOperationsFromHistoryStack = ( + stack: HistoryBatchLike[] | undefined, + operations: readonly Operation[] +) => { + if (!stack || operations.length === 0) { + return + } + + for (let batchIndex = stack.length - 1; batchIndex >= 0; batchIndex -= 1) { + const batch = stack[batchIndex] + const batchOperations = batch?.operations + + if (!Array.isArray(batchOperations)) { + throw new Error('Cannot remove rejected Yjs operations from history.') + } + + if (batchOperations.length < operations.length) { + continue + } + + const start = batchOperations.length - operations.length + + if ( + operations.every((operation, index) => + operationsEqual(operation, batchOperations[start + index]) + ) + ) { + batchOperations.splice(start, operations.length) + + if ( + batchOperations.length === 0 && + (batch.statePatches?.length ?? 0) === 0 + ) { + stack.splice(batchIndex, 1) + } + + return + } + } +} + +export const removeRejectedYjsOperationsFromHistory = ( + editor: Editor, + operations: readonly Operation[] +) => { + const history = readEditorHistory(editor) + + if (!history) { + return + } + + removeOperationsFromHistoryStack(history.undos, operations) + removeOperationsFromHistoryStack(history.redos, operations) +} + +export const removeRejectedYjsOperationsFromHistoryAfterCommit = ( + editor: Editor, + operations: readonly Operation[] +) => { + const remove = () => { + removeRejectedYjsOperationsFromHistory(editor, operations) + } + + if (typeof queueMicrotask === 'function') { + queueMicrotask(remove) + } else { + void Promise.resolve().then(remove) + } +} diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index 9da5074a2c..b2e1e98d3f 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -337,6 +337,46 @@ const getYjsNodeIf = (root: Y.XmlElement, path: number[]) => { } } +const materializeEmptyYjsText = ( + root: Y.XmlElement, + path: number[] +): Y.XmlText | null => { + const index = path.at(-1) + + if (index !== 0) { + return null + } + + const parentPath = path.slice(0, -1) + const parent = parentPath.length === 0 ? root : getYjsNodeIf(root, parentPath) + + if (!(parent instanceof Y.XmlElement)) { + return null + } + if (getYjsVisibleChildren(root, parent).length > 0) { + return null + } + + const text = createYjsText('', {}) + + insertYjsChild(root, parent, 0, text) + + return text +} + +const getYjsTextForInsert = (root: Y.XmlElement, path: number[]) => { + const target = getYjsNodeIf(root, path) + + if (target instanceof Y.XmlText) { + return target + } + if (target) { + return target + } + + return materializeEmptyYjsText(root, path) +} + const isEmptyYjsText = (node: Y.XmlElement | Y.XmlText) => node instanceof Y.XmlText && getYjsTextContent(node).length === 0 @@ -410,7 +450,7 @@ export const applySlateOperationToYjs = ( switch (operation.type) { case 'insert_text': { - const text = getYjsNode(root, operation.path) + const text = getYjsTextForInsert(root, operation.path) if (!(text instanceof Y.XmlText)) { throw new Error('insert_text target is not a Y.XmlText.') diff --git a/packages/slate-yjs/src/core/provider.ts b/packages/slate-yjs/src/core/provider.ts new file mode 100644 index 0000000000..e9945e6807 --- /dev/null +++ b/packages/slate-yjs/src/core/provider.ts @@ -0,0 +1,82 @@ +import type { + YjsProviderLike, + YjsProviderStatus, + YjsProviderStatusPayload, + YjsProviderSyncedPayload, +} from './types' + +export const normalizeYjsProviderStatus = ( + value: YjsProviderStatusPayload | unknown +): YjsProviderStatus | null => { + if (typeof value === 'string') { + return value + } + + if ( + value && + typeof value === 'object' && + 'status' in value && + typeof value.status === 'string' + ) { + return value.status + } + + return null +} + +export const normalizeYjsProviderSynced = ( + value: YjsProviderSyncedPayload | unknown +): boolean | null => { + if (typeof value === 'boolean') { + return value + } + + if ( + value && + typeof value === 'object' && + 'state' in value && + typeof value.state === 'boolean' + ) { + return value.state + } + + if ( + value && + typeof value === 'object' && + 'synced' in value && + typeof value.synced === 'boolean' + ) { + return value.synced + } + + return null +} + +export const readYjsProviderStatus = (provider: YjsProviderLike | undefined) => + normalizeYjsProviderStatus(provider?.status) + +export const readYjsProviderSynced = (provider: YjsProviderLike | undefined) => + normalizeYjsProviderSynced(provider?.synced) + +export const connectedFromYjsProviderStatus = ( + status: YjsProviderStatus | null, + fallback: boolean +) => { + if (status === 'connected') { + return true + } + + if (status === 'connecting' || status === 'disconnected') { + return false + } + + return fallback +} + +export const isPromiseLike = (value: unknown): value is PromiseLike => + Boolean( + value && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' + ) diff --git a/packages/slate-yjs/src/core/split-history.ts b/packages/slate-yjs/src/core/split-history.ts new file mode 100644 index 0000000000..3a6be5cfb8 --- /dev/null +++ b/packages/slate-yjs/src/core/split-history.ts @@ -0,0 +1,212 @@ +import type { Path } from 'slate' +import * as Y from 'yjs' + +import { + getYjsLength, + getYjsNode, + getYjsTextContent, + getYjsVisibleChildren, + SPLIT_UNDO_TEXT_ATTRIBUTE, +} from './document' + +export type SplitHistory = { + absorbedRemoteSplit?: boolean + elementPath: Path + elementPosition: number + elementProperties: Record + rightText: string + textPath: Path + textProperties: Record + undoneWhileDisconnected?: boolean +} + +export type PendingTextSplitHistory = Omit< + SplitHistory, + 'elementPosition' | 'elementProperties' +> + +export const SPLIT_HISTORY_META = 'slate-yjs:split-history' + +const appendTextContent = ( + target: Y.XmlText, + source: Y.XmlText, + extraAttributes: Record = {} +) => { + let offset = getYjsLength(target) + let insertedText = '' + + for (const delta of source.toDelta()) { + if (typeof delta.insert !== 'string' || delta.insert.length === 0) { + continue + } + + target.insert(offset, delta.insert, { + ...(delta.attributes ?? {}), + ...extraAttributes, + }) + offset += delta.insert.length + insertedText += delta.insert + } + + return insertedText +} + +export const appendElementText = ( + root: Y.XmlElement, + target: Y.XmlText, + element: Y.XmlElement, + extraAttributes: Record = {} +) => { + let insertedText = '' + + for (const child of getYjsVisibleChildren(root, element)) { + if (child instanceof Y.XmlText) { + insertedText += appendTextContent(target, child, extraAttributes) + } else { + insertedText += appendElementText(root, target, child, extraAttributes) + } + } + + return insertedText +} + +const findLastVisibleText = ( + root: Y.XmlElement, + node: Y.XmlElement | Y.XmlText +): Y.XmlText | null => { + if (node instanceof Y.XmlText) { + return node + } + + const children = getYjsVisibleChildren(root, node) + + for (let index = children.length - 1; index >= 0; index--) { + const child = children[index] + const text = child ? findLastVisibleText(root, child) : null + + if (text) { + return text + } + } + + return null +} + +export const getTrailingSplitUndoText = (text: Y.XmlText) => { + let offset = getYjsLength(text) + let value = '' + + for (const delta of [...text.toDelta()].reverse()) { + if (typeof delta.insert !== 'string' || delta.insert.length === 0) { + return value ? { length: value.length, offset, value } : null + } + + if (delta.attributes?.[SPLIT_UNDO_TEXT_ATTRIBUTE] === true) { + offset -= delta.insert.length + value = delta.insert + value + continue + } + + break + } + + return value ? { length: value.length, offset, value } : null +} + +export const clearSplitUndoTextAttribute = ( + text: Y.XmlText, + offset: number, + length: number +) => { + text.format(offset, length, { + [SPLIT_UNDO_TEXT_ATTRIBUTE]: null, + } as unknown as Record) +} + +export const getVisibleText = ( + root: Y.XmlElement, + node: Y.XmlElement | Y.XmlText +): string => { + if (node instanceof Y.XmlText) { + return getYjsTextContent(node) + } + + return getYjsVisibleChildren(root, node) + .map((child) => getVisibleText(root, child)) + .join('') +} + +export const findSplitUndoTextRepairs = (root: Y.XmlElement) => { + const repairs: Array<{ + hasRemoteSplitBoundary: boolean + length: number + offset: number + text: Y.XmlText + }> = [] + + const visit = (parent: Y.XmlElement) => { + const children = getYjsVisibleChildren(root, parent) + + for (let index = 0; index < children.length; index++) { + const left = children[index] + + if (!(left instanceof Y.XmlElement)) { + continue + } + + const leftText = findLastVisibleText(root, left) + const right = children[index + 1] + const trailing = leftText ? getTrailingSplitUndoText(leftText) : null + + if (leftText && trailing) { + repairs.push({ + hasRemoteSplitBoundary: right + ? getVisibleText(root, right).startsWith(trailing.value) + : false, + length: trailing.length, + offset: trailing.offset, + text: leftText, + }) + } + } + + for (const child of children) { + if (child instanceof Y.XmlElement) { + visit(child) + } + } + } + + visit(root) + + return repairs +} + +export const isSplitHistory = (value: unknown): value is SplitHistory => + typeof value === 'object' && + value !== null && + Array.isArray((value as SplitHistory).elementPath) && + Array.isArray((value as SplitHistory).textPath) && + typeof (value as SplitHistory).rightText === 'string' && + typeof (value as SplitHistory).elementPosition === 'number' + +export const nextPath = (path: Path) => { + const index = path.at(-1) + + if (index === undefined) { + throw new Error('Cannot get a next path for the root.') + } + + return [...path.slice(0, -1), index + 1] +} + +export const getYjsNodeIf = (root: Y.XmlElement, path: Path) => { + try { + return getYjsNode(root, path) + } catch { + return null + } +} + +export const pathsEqual = (a: Path, b: Path) => + a.length === b.length && a.every((part, index) => part === b[index]) diff --git a/packages/slate-yjs/test/merge-node-contract.spec.ts b/packages/slate-yjs/test/merge-node-contract.spec.ts index 862cc72f81..a463d8ad19 100644 --- a/packages/slate-yjs/test/merge-node-contract.spec.ts +++ b/packages/slate-yjs/test/merge-node-contract.spec.ts @@ -1,16 +1,21 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createEditor, type Descendant } from 'slate' +import type { Descendant } from 'slate' import { Editor } from 'slate/internal' -import * as Y from 'yjs' -import { createYjsExtension } from '../src' import { readSlateValueFromYjs } from '../src/core/document' - -type Peer = { - doc: Y.Doc - editor: ReturnType -} +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsNodeAt, + getYjsState, + type Peer, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' const paragraph = (text: string): Descendant => ({ type: 'paragraph', @@ -40,117 +45,21 @@ const createPeer = ( clientId: string, seedUpdate?: Uint8Array, children: Descendant[] = initialValue() -): Peer => { - const editor = createEditor() - - Editor.replace(editor, { - children, - selection: null, - marks: null, - }) - - const doc = new Y.Doc() - - if (seedUpdate) { - Y.applyUpdate(doc, seedUpdate) - } - - editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) - - return { doc, editor } -} +): Peer => createYjsPeer({ children, clientId, seedUpdate }) const createPeers = ( clientIds: string[], children: Descendant[] = initialValue() ) => { - const [firstClientId, ...remainingClientIds] = clientIds - - if (!firstClientId) { - return [] - } - - const firstPeer = createPeer(firstClientId, undefined, children) - const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) - - return [ - firstPeer, - ...remainingClientIds.map((clientId) => - createPeer(clientId, seedUpdate, children) - ), - ] -} - -const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) - -const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { - peer.editor.update((tx) => { - fn((tx as any).yjs) - }) + return createSeededYjsPeers({ children, clientIds }) } -const paragraphTexts = (peer: Peer) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) - -const yjsNodeAt = (peer: Peer, path: number[]): Y.XmlElement | Y.XmlText => { - let current: Y.XmlElement | Y.XmlText = yjsState(peer).root() - - for (const index of path) { - if (current instanceof Y.XmlText) { - throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) - } - - const child = current - .toArray() - .filter( - (value): value is Y.XmlElement | Y.XmlText => - value instanceof Y.XmlElement || value instanceof Y.XmlText - )[index] - - if (!child) { - throw new Error(`No Yjs node at ${path.join('.')}`) - } - - current = child - } - - return current -} - -const assertNoRootSnapshot = (peer: Peer) => { - assert.equal( - yjsState(peer) - .trace() - .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), - false - ) -} - -const syncConnected = (peers: Peer[]) => { - for (const source of peers) { - if (!yjsState(source).connected()) { - continue - } - - const update = Y.encodeStateAsUpdate(source.doc) - - for (const target of peers) { - if (source === target || !yjsState(target).connected()) { - continue - } - - Y.applyUpdate(target.doc, update, source) - } - } -} - -const assertAllTexts = (peers: Peer[], expected: string[]) => { - for (const peer of peers) { - assert.deepEqual(paragraphTexts(peer), expected) - } -} +const yjsState = getYjsState +const yjsUpdate = runYjsUpdate +const paragraphTexts = getParagraphTexts +const yjsNodeAt = getYjsNodeAt +const syncConnected = syncConnectedPeers +const assertAllTexts = assertPeerTexts const mergeSecondParagraph = (peer: Peer) => { peer.editor.update((tx) => { diff --git a/packages/slate-yjs/test/remove-node-contract.spec.ts b/packages/slate-yjs/test/remove-node-contract.spec.ts index 23a952dcc0..505b79a6c1 100644 --- a/packages/slate-yjs/test/remove-node-contract.spec.ts +++ b/packages/slate-yjs/test/remove-node-contract.spec.ts @@ -1,15 +1,17 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createEditor, type Descendant } from 'slate' -import { Editor } from 'slate/internal' -import * as Y from 'yjs' - -import { createYjsExtension } from '../src' - -type Peer = { - doc: Y.Doc - editor: ReturnType -} +import type { Descendant } from 'slate' +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsState, + type Peer, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' const paragraph = (text: string): Descendant => ({ type: 'paragraph', @@ -22,87 +24,18 @@ const initialValue = () => [ paragraph('gamma'), ] -const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => { - const editor = createEditor() - - Editor.replace(editor, { - children: initialValue(), - selection: null, - marks: null, - }) - - const doc = new Y.Doc() - - if (seedUpdate) { - Y.applyUpdate(doc, seedUpdate) - } - - editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) - - return { doc, editor } -} +const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => + createYjsPeer({ children: initialValue(), clientId, seedUpdate }) const createPeers = (clientIds: string[]) => { - const [firstClientId, ...remainingClientIds] = clientIds - - if (!firstClientId) { - return [] - } - - const firstPeer = createPeer(firstClientId) - const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) - - return [ - firstPeer, - ...remainingClientIds.map((clientId) => createPeer(clientId, seedUpdate)), - ] + return createSeededYjsPeers({ children: initialValue(), clientIds }) } -const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) - -const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { - peer.editor.update((tx) => { - fn((tx as any).yjs) - }) -} - -const paragraphTexts = (peer: Peer) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) - -const assertNoRootSnapshot = (peer: Peer) => { - assert.equal( - yjsState(peer) - .trace() - .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), - false - ) -} - -const syncConnected = (peers: Peer[]) => { - for (const source of peers) { - if (!yjsState(source).connected()) { - continue - } - - const update = Y.encodeStateAsUpdate(source.doc) - - for (const target of peers) { - if (source === target || !yjsState(target).connected()) { - continue - } - - Y.applyUpdate(target.doc, update, source) - } - } -} - -const assertAllTexts = (peers: Peer[], expected: string[]) => { - for (const peer of peers) { - assert.deepEqual(paragraphTexts(peer), expected) - } -} +const yjsState = getYjsState +const yjsUpdate = runYjsUpdate +const paragraphTexts = getParagraphTexts +const syncConnected = syncConnectedPeers +const assertAllTexts = assertPeerTexts const removeMiddleBlock = (peer: Peer) => { peer.editor.update((tx) => { diff --git a/packages/slate-yjs/test/replace-fragment-contract.spec.ts b/packages/slate-yjs/test/replace-fragment-contract.spec.ts index e00f683487..efcc9f5b0d 100644 --- a/packages/slate-yjs/test/replace-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/replace-fragment-contract.spec.ts @@ -1,21 +1,25 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createEditor, type Descendant, type Operation } from 'slate' -import { Editor } from 'slate/internal' -import * as Y from 'yjs' - -import { createYjsExtension } from '../src' - -type Peer = { - doc: Y.Doc - editor: ReturnType -} +import type { Descendant, Operation } from 'slate' +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsNodeAt, + getYjsState, + type Peer, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' const clientIds = { a: 1, b: 2, c: 3, } as const +const numericClientIds: Record = { ...clientIds } const paragraph = (text: string): Descendant => ({ type: 'paragraph', @@ -35,114 +39,28 @@ const createPeer = ( clientId: keyof typeof clientIds, seedUpdate?: Uint8Array, children = initialValue() -): Peer => { - const editor = createEditor() - - Editor.replace(editor, { +): Peer => + createYjsPeer({ children, - selection: null, - marks: null, + clientId, + numericClientId: clientIds[clientId], + seedUpdate, }) - const doc = new Y.Doc() - - doc.clientID = clientIds[clientId] - - if (seedUpdate) { - Y.applyUpdate(doc, seedUpdate) - } - - editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) - - return { doc, editor } -} - const createPeers = (ids: Array) => { - const [firstClientId, ...remainingClientIds] = ids - - if (!firstClientId) { - return [] - } - - const firstPeer = createPeer(firstClientId) - const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) - - return [ - firstPeer, - ...remainingClientIds.map((clientId) => createPeer(clientId, seedUpdate)), - ] -} - -const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) - -const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { - peer.editor.update((tx) => { - fn((tx as any).yjs) + return createSeededYjsPeers({ + children: initialValue(), + clientIds: ids, + numericClientIds, }) } -const paragraphTexts = (peer: Peer) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) - -const yjsNodeAt = (peer: Peer, path: number[]): Y.XmlElement | Y.XmlText => { - let current: Y.XmlElement | Y.XmlText = yjsState(peer).root() - - for (const index of path) { - if (current instanceof Y.XmlText) { - throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) - } - - const child = current - .toArray() - .filter( - (value): value is Y.XmlElement | Y.XmlText => - value instanceof Y.XmlElement || value instanceof Y.XmlText - )[index] - - if (!child) { - throw new Error(`No Yjs node at ${path.join('.')}`) - } - - current = child - } - - return current -} - -const assertNoRootSnapshot = (peer: Peer) => { - assert.equal( - yjsState(peer) - .trace() - .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), - false - ) -} - -const syncConnected = (peers: Peer[]) => { - for (const source of peers) { - if (!yjsState(source).connected()) { - continue - } - - const update = Y.encodeStateAsUpdate(source.doc) - - for (const target of peers) { - if (source === target || !yjsState(target).connected()) { - continue - } - - Y.applyUpdate(target.doc, update, source) - } - } -} - -const assertAllTexts = (peers: Peer[], expected: string[]) => { - for (const peer of peers) { - assert.deepEqual(paragraphTexts(peer), expected) - } -} +const yjsState = getYjsState +const yjsUpdate = runYjsUpdate +const paragraphTexts = getParagraphTexts +const yjsNodeAt = getYjsNodeAt +const syncConnected = syncConnectedPeers +const assertAllTexts = assertPeerTexts const replaceAlphaWithFragment = (peer: Peer) => { const operation: Operation = { diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts index 056cab9af7..d20f241d44 100644 --- a/packages/slate-yjs/test/split-node-contract.spec.ts +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -1,15 +1,19 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createEditor, type Descendant } from 'slate' +import type { Descendant } from 'slate' import { Editor } from 'slate/internal' -import * as Y from 'yjs' - -import { createYjsExtension } from '../src' - -type Peer = { - doc: Y.Doc - editor: ReturnType -} +import { + assertNoRootSnapshot, + assertPeerTexts, + createSeededYjsPeers, + createYjsPeer, + getParagraphTexts, + getYjsNodeAt, + getYjsState, + type Peer, + runYjsUpdate, + syncConnectedPeers, +} from './support/collaboration' const paragraph = (text: string): Descendant => ({ type: 'paragraph', @@ -24,114 +28,18 @@ const createPeer = ( clientId: string, seedUpdate?: Uint8Array, children = initialValue() -): Peer => { - const editor = createEditor() - - Editor.replace(editor, { - children, - selection: null, - marks: null, - }) - - const doc = new Y.Doc() - - if (seedUpdate) { - Y.applyUpdate(doc, seedUpdate) - } - - editor.extend(createYjsExtension({ clientId, doc, rootName: 'slate' })) - - return { doc, editor } -} +): Peer => createYjsPeer({ children, clientId, seedUpdate }) const createPeers = (clientIds: string[], children = initialValue()) => { - const [firstClientId, ...remainingClientIds] = clientIds - - if (!firstClientId) { - return [] - } - - const firstPeer = createPeer(firstClientId, undefined, children) - const seedUpdate = Y.encodeStateAsUpdate(firstPeer.doc) - - return [ - firstPeer, - ...remainingClientIds.map((clientId) => - createPeer(clientId, seedUpdate, children) - ), - ] -} - -const yjsState = (peer: Peer) => peer.editor.read((state) => (state as any).yjs) - -const yjsUpdate = (peer: Peer, fn: (tx: any) => void) => { - peer.editor.update((tx) => { - fn((tx as any).yjs) - }) -} - -const paragraphTexts = (peer: Peer) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) - -const yjsNodeAt = (peer: Peer, path: number[]): Y.XmlElement | Y.XmlText => { - let current: Y.XmlElement | Y.XmlText = yjsState(peer).root() - - for (const index of path) { - if (current instanceof Y.XmlText) { - throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) - } - - const child = current - .toArray() - .filter( - (value): value is Y.XmlElement | Y.XmlText => - value instanceof Y.XmlElement || value instanceof Y.XmlText - )[index] - - if (!child) { - throw new Error(`No Yjs node at ${path.join('.')}`) - } - - current = child - } - - return current + return createSeededYjsPeers({ children, clientIds }) } -const assertNoRootSnapshot = (peer: Peer) => { - assert.equal( - yjsState(peer) - .trace() - .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), - false - ) -} - -const syncConnected = (peers: Peer[]) => { - for (const source of peers) { - if (!yjsState(source).connected()) { - continue - } - - const update = Y.encodeStateAsUpdate(source.doc) - - for (const target of peers) { - if (source === target || !yjsState(target).connected()) { - continue - } - - Y.applyUpdate(target.doc, update, source) - } - } -} - -const assertAllTexts = (peers: Peer[], expected: string[]) => { - for (const [index, peer] of peers.entries()) { - assert.deepEqual(paragraphTexts(peer), expected, `peer ${index}`) - } -} +const yjsState = getYjsState +const yjsUpdate = runYjsUpdate +const paragraphTexts = getParagraphTexts +const yjsNodeAt = getYjsNodeAt +const syncConnected = syncConnectedPeers +const assertAllTexts = assertPeerTexts const splitParagraph = (peer: Peer) => { peer.editor.update((tx) => { diff --git a/packages/slate-yjs/test/structural-soak-contract.spec.ts b/packages/slate-yjs/test/structural-soak-contract.spec.ts index 111fb830b9..34d1a4bc74 100644 --- a/packages/slate-yjs/test/structural-soak-contract.spec.ts +++ b/packages/slate-yjs/test/structural-soak-contract.spec.ts @@ -32,6 +32,13 @@ const appendTexts: Record = { d: ' Eve', } +const replacementTexts: Record = { + a: 'Ada canonical snapshot.', + b: 'Lin canonical snapshot.', + c: 'Ken canonical snapshot.', + d: 'Eve canonical snapshot.', +} + const paragraph = (text: string): Descendant => ({ children: [{ text }], type: 'paragraph', @@ -205,6 +212,91 @@ const assertNoNestedParagraphs = (peers: readonly Peer[]) => { } } +const hasElementDescendantInsideParagraph = ( + node: Descendant, + insideParagraph = false +): boolean => { + if (!hasChildren(node)) { + return false + } + + if (insideParagraph) { + return true + } + + const isParagraph = 'type' in node && node.type === 'paragraph' + + return node.children.some((child) => + hasElementDescendantInsideParagraph(child, isParagraph) + ) +} + +const assertNoElementDescendantsInsideParagraphs = (peers: readonly Peer[]) => { + for (const peer of peers) { + const value = editorValueOf(peer) + const yjsValue = readSlateValueFromYjs(getYjsState(peer).root()) + + assert.equal( + value.some((node) => hasElementDescendantInsideParagraph(node)), + false, + JSON.stringify(value) + ) + assert.equal( + yjsValue.some((node) => hasElementDescendantInsideParagraph(node)), + false, + JSON.stringify(yjsValue) + ) + } +} + +const getNodeAtPath = ( + children: readonly Descendant[], + path: readonly number[] +): Descendant | null => { + let current: { children: readonly Descendant[] } | Descendant = { children } + + for (const index of path) { + if (!hasChildren(current)) { + return null + } + + const child = current.children[index] + + if (!child) { + return null + } + + current = child + } + + return current as Descendant +} + +const assertSelectionsTargetText = (peers: readonly Peer[]) => { + for (const peer of peers) { + const selection = peer.editor.read((state) => state.selection.get()) as { + anchor: { path: number[] } + focus: { path: number[] } + } | null + + if (!selection) { + continue + } + + const value = editorValueOf(peer) + + for (const point of [selection.anchor, selection.focus]) { + const node = getNodeAtPath(value, point.path) + + assert.equal( + !!node && isText(node), + true, + JSON.stringify({ selection, value }) + ) + } + } +} + const sync = (peers: Record) => { const peerList = allPeers(peers) @@ -323,6 +415,29 @@ const removeSecondBlock = (peer: Peer) => { }) } +const replaceDocument = (peer: Peer, peerId: PeerId) => { + const children = editorValueOf(peer) + const text = replacementTexts[peerId] + + peer.editor.update((tx) => { + tx.operations.replay([ + { + children, + index: 0, + newChildren: [paragraph(text)], + newSelection: { + anchor: { path: [0, 0], offset: text.length }, + focus: { path: [0, 0], offset: text.length }, + }, + path: [], + root: 'main', + selection: null, + type: 'replace_children', + }, + ]) + }) +} + const wrapFirstBlock = (peer: Peer) => { peer.editor.update((tx) => { tx.selection.clear() @@ -399,6 +514,22 @@ const deleteFirstFragment = (peer: Peer) => { }) } +const deleteBackwardFromFirstBlockEnd = (peer: Peer) => { + const entry = firstBlockTextEntry(peer, 'last') + + if (!entry || entry.text.length === 0) { + return + } + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, + }) + tx.text.deleteBackward({ unit: 'character' }) + }) +} + const reconcilePeer = (peer: Peer) => { runYjsUpdate(peer, (yjs) => yjs.reconcile()) } @@ -496,6 +627,172 @@ describe('@slate/yjs structural soak contract', () => { assertPeerParagraphTexts(allPeers(peers), ['Hello wo']) }) + it('keeps structural edits from projecting block placeholders inside paragraphs', () => { + const peers = createAwarePeers() + + runCommand(peers, 'a', splitFirstText) + runCommand(peers, 'c', deleteFirstFragment) + runCommand(peers, 'c', (peer) => { + peer.editor.update((tx) => { + tx.nodes.set({ role: 'title' } as never, { at: [0] }) + }) + }) + reconcilePeer(peers.d) + runCommand(peers, 'd', deleteBackwardFromFirstBlockEnd) + setConnected(peers, 'a', true) + runCommand(peers, 'c', removeSecondBlock) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps random-control seed 85 from missing Yjs nodes', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', reconcilePeer) + runCommand(peers, 'a', moveFirstBlockDown) + runCommand(peers, 'a', mergeSecondBlock) + runCommand(peers, 'd', replaceDocument) + runCommand(peers, 'c', moveFirstBlockAfterSecond) + setConnected(peers, 'b', true) + runCommand(peers, 'c', moveFirstBlockDown) + runCommand(peers, 'b', splitFirstText) + runCommand(peers, 'c', unsetFirstBlockRole) + + assert.doesNotThrow(() => { + runCommand(peers, 'd', appendText) + }) + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps offline structural mix seed 108 from nesting paragraphs', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'd', wrapFirstBlock) + runCommand(peers, 'b', moveFirstBlockDown) + runCommand(peers, 'c', deleteBackwardFromFirstBlockEnd) + runCommand(peers, 'b', unsetFirstBlockRole) + runCommand(peers, 'c', liftFirstWrappedBlock) + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'd', insertExclamation) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps structural mix seed 42 selections on text leaves', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'c', splitFirstText) + runCommand(peers, 'b', moveFirstBlockDown) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps random-control seed 42 disconnected remove selections on text leaves', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', insertExclamation) + runCommand(peers, 'c', wrapFirstBlock) + runCommand(peers, 'b', appendText) + runCommand(peers, 'b', splitFirstText) + reconcilePeer(peers.d) + runCommand(peers, 'd', moveFirstBlockAfterSecond) + runCommand(peers, 'c', removeSecondBlock) + runCommand(peers, 'c', insertExclamation) + setConnected(peers, 'a', true) + setConnected(peers, 'd', false) + runCommand(peers, 'c', mergeSecondBlock) + runCommand(peers, 'c', unwrapFirstBlock) + runCommand(peers, 'd', removeSecondBlock) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps structural mix seed 43 selections on text leaves', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', (peer, peerId) => { + const entry = firstBlockTextEntry(peer, 'last') + + if (!entry) { + return + } + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, + }) + tx.fragment.insert([{ text: `${peerId} fragment` }]) + }) + }) + runCommand(peers, 'a', splitFirstText) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'd', liftFirstWrappedBlock) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'c', appendText) + runCommand(peers, 'b', moveFirstBlockDown) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps structural mix seed 46 selections on text leaves', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', (peer) => { + peer.editor.update((tx) => { + tx.nodes.set({ role: 'title' } as never, { at: [0] }) + }) + }) + runCommand(peers, 'a', mergeSecondBlock) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'a', insertExclamation) + runCommand(peers, 'b', mergeSecondBlock) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps structural mix seed 49 selections on text leaves', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'c', moveFirstBlockAfterSecond) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'a', moveFirstBlockAfterSecond) + runCommand(peers, 'b', unsetFirstBlockRole) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + + it('keeps structural mix seed 55 selections on text leaves', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'a', moveFirstBlockAfterSecond) + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'a', insertExclamation) + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'c', mergeSecondBlock) + + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + assertSelectionsTargetText(allPeers(peers)) + }) + it('elides stale move_node source paths after concurrent structural removal', () => { const peer = createPeers().a const operation: Operation = { diff --git a/packages/slate-yjs/test/support/collaboration.ts b/packages/slate-yjs/test/support/collaboration.ts index 4a34108282..a70da5bcd9 100644 --- a/packages/slate-yjs/test/support/collaboration.ts +++ b/packages/slate-yjs/test/support/collaboration.ts @@ -9,6 +9,8 @@ import type { YjsAwarenessChange, YjsAwarenessLike, YjsProviderLike, + YjsState, + YjsTx, } from '../../src/core/types' export type Peer = { @@ -17,6 +19,14 @@ export type Peer = { editor: ReturnType } +type YjsStateView = { + yjs: YjsState +} + +type YjsTxView = { + yjs: YjsTx +} + export class FakeAwareness implements YjsAwarenessLike { readonly clientID: number readonly doc: { clientID: number } @@ -199,11 +209,11 @@ export const getVisibleYjsNodeAt = ( ): Y.XmlElement | Y.XmlText => getYjsNode(getYjsState(peer).root(), path) export const getYjsState = (peer: Peer) => - peer.editor.read((state) => (state as any).yjs) + peer.editor.read((state) => (state as YjsStateView).yjs) -export const runYjsUpdate = (peer: Peer, fn: (tx: any) => void) => { +export const runYjsUpdate = (peer: Peer, fn: (tx: YjsTx) => void) => { peer.editor.update((tx) => { - fn((tx as any).yjs) + fn((tx as YjsTxView).yjs) }) } @@ -235,7 +245,7 @@ export const assertNoRootSnapshot = (peer: Peer) => { } export const assertPeerTexts = (peers: Peer[], expected: string[]) => { - for (const peer of peers) { - assert.deepEqual(getParagraphTexts(peer), expected) + for (const [index, peer] of peers.entries()) { + assert.deepEqual(getParagraphTexts(peer), expected, `peer ${index}`) } } diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts index 25f09a5ed0..7b03e6b71f 100644 --- a/playwright/integration/examples/yjs-collaboration.test.ts +++ b/playwright/integration/examples/yjs-collaboration.test.ts @@ -103,6 +103,14 @@ const expectNoPeerNestedParagraphs = async (page: Page) => { } } +const expectNoPeerInvalidParagraphDescendants = async (page: Page) => { + for (const peer of ['a', 'b', 'c', 'd'] as const) { + await expect( + peerTextbox(page, peer).locator('p p, p div, p blockquote') + ).toHaveCount(0) + } +} + const watchStructuralBrowserErrors = (page: Page) => { const errors: string[] = [] @@ -1531,6 +1539,125 @@ test.describe('yjs collaboration example', () => { expect(errors).toEqual([]) }) + test('keeps structural edits from rendering placeholder divs inside paragraphs', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['a', 'split-node'], + ['c', 'delete-fragment'], + ['c', 'set-node'], + ['d', 'reconcile'], + ['c', 'redo'], + ['d', 'delete-backward'], + ['a', 'connect'], + ['c', 'remove-node'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + + test('keeps random-control seed 85 from missing Yjs nodes', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'reconcile'], + ['a', 'move-down'], + ['a', 'merge-node'], + ['d', 'replace'], + ['c', 'move'], + ['b', 'connect'], + ['c', 'move-down'], + ['b', 'split-node'], + ['c', 'unset-node'], + ['d', 'append'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + + test('keeps offline structural mix seed 108 from nesting paragraphs', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'disconnect'], + ['b', 'wrap-node'], + ['d', 'wrap-node'], + ['b', 'move-down'], + ['c', 'delete-backward'], + ['b', 'unset-node'], + ['c', 'lift'], + ['b', 'merge-node'], + ['d', 'insert-text'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + + test('keeps structural mix seed 42 from leaf-path crashes', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'disconnect'], + ['b', 'wrap-node'], + ['c', 'split-node'], + ['b', 'move-down'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + test('keeps offline structural mix seed 16 from losing root text boundaries', async ({ page, }) => { @@ -1642,6 +1769,7 @@ test.describe('yjs collaboration example', () => { ['d', 'disconnect'], ['c', 'merge-node'], ['c', 'unwrap'], + ['d', 'remove-node'], ], { errors } ) @@ -1661,6 +1789,121 @@ test.describe('yjs collaboration example', () => { expect(errors).toEqual([]) }) + test('keeps structural mix seed 43 from leaf-path crashes', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'disconnect'], + ['b', 'insert-fragment'], + ['a', 'split-node'], + ['b', 'wrap-node'], + ['d', 'lift'], + ['b', 'wrap-node'], + ['c', 'append'], + ['b', 'move-down'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + + test('keeps structural mix seed 46 from leaf-path crashes', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'disconnect'], + ['b', 'set-node'], + ['a', 'merge-node'], + ['b', 'wrap-node'], + ['a', 'insert-text'], + ['b', 'merge-node'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + + test('keeps structural mix seed 49 from leaf-path crashes', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'disconnect'], + ['b', 'merge-node'], + ['c', 'move'], + ['b', 'wrap-node'], + ['a', 'move'], + ['b', 'unset-node'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + + test('keeps structural mix seed 55 block quote from leaf-path crashes', async ({ + page, + }) => { + const errors = watchStructuralBrowserErrors(page) + + await openExample(page, 'yjs-collaboration', { + ready: { editor: 'visible' }, + surface: { scope: '#yjs-peer-a-editor-surface' }, + }) + + await runPeerActions( + page, + [ + ['b', 'disconnect'], + ['b', 'wrap-node'], + ['a', 'move'], + ['b', 'wrap-node'], + ['a', 'insert-text'], + ['b', 'merge-node'], + ['c', 'merge-node'], + ], + { errors } + ) + + await expectAllPeerTextboxesAlive(page) + await expectNoPeerInvalidParagraphDescendants(page) + expect(errors).toEqual([]) + }) + test('keeps random-control seed 96 from repeating missing Yjs path 1.0', async ({ page, }) => { diff --git a/scripts/proof/yjs-collaboration-soak.mjs b/scripts/proof/yjs-collaboration-soak.mjs new file mode 100644 index 0000000000..929353675b --- /dev/null +++ b/scripts/proof/yjs-collaboration-soak.mjs @@ -0,0 +1,684 @@ +#!/usr/bin/env bun + +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium } from '@playwright/test' + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) +const PORT = process.env.PORT ?? '3100' +const baseUrl = + process.env.SOAK_BASE_URL ?? + process.env.PLAYWRIGHT_BASE_URL ?? + `http://localhost:${PORT}` +const TARGET_URL = + process.env.SOAK_URL ?? + `${baseUrl.replace(/\/$/, '')}/examples/yjs-collaboration` +const CDP = process.env.SOAK_CDP ?? 'http://127.0.0.1:9222' +const DURATION_MS = Number(process.env.SOAK_MS ?? 3 * 60 * 60 * 1000) +const ACTION_DELAY_MS = Number(process.env.SOAK_ACTION_DELAY_MS ?? 1000) +const REPORT_EVERY_MS = Number(process.env.SOAK_REPORT_EVERY_MS ?? 60 * 1000) +const RUN_ID = + process.env.SOAK_RUN_ID ?? new Date().toISOString().replace(/[:.]/g, '-') +const OUTPUT_ROOT = + process.env.SOAK_OUTPUT_ROOT ?? 'test-results/yjs-collaboration-soak' +const OUT_DIR = path.resolve(repoRoot, OUTPUT_ROOT, RUN_ID) +const LOG_PATH = path.join(OUT_DIR, 'events.jsonl') +const SUMMARY_PATH = path.join(OUT_DIR, 'summary.md') +const SHOULD_LAUNCH_BROWSER = process.env.SOAK_LAUNCH !== '0' +const HAS_EXTERNAL_URL = Boolean( + process.env.SOAK_URL || + process.env.SOAK_BASE_URL || + process.env.PLAYWRIGHT_BASE_URL +) +const SHOULD_START_SERVER = + process.env.SOAK_START_SERVER !== '0' && !HAS_EXTERNAL_URL + +const PEERS = ['a', 'b', 'c', 'd'] +const ACTIONS = [ + 'append', + 'insert-text', + 'split-node', + 'merge-node', + 'wrap-node', + 'unwrap', + 'lift', + 'insert-fragment', + 'delete-fragment', + 'delete-backward', + 'move', + 'replace', + 'set-node', + 'unset-node', + 'mark-bold', + 'undo', + 'redo', + 'move-down', + 'remove-node', + 'disconnect', + 'connect', + 'reconcile', +] + +const ERROR_RE = + /Cannot|No Yjs|hydration|nested|descendant|merge Yjs|end text node|

cannot contain|uncaught|error/i +const IGNORE_CONSOLE_RE = + /\[HMR\] Invalid message|Download the React DevTools|favicon\.ico/i +const LOCAL_CLIENT_CURSOR_RE = /101:0/ + +fs.mkdirSync(OUT_DIR, { recursive: true }) + +const startedAt = Date.now() +const issues = new Map() +const metrics = { + actions: 0, + consoleErrors: 0, + hardResets: 0, + iterations: 0, + pageErrors: 0, + scenarios: Object.create(null), + skippedDisabled: 0, +} + +let page +let browser +let server +let lastAction = null +let lastReportAt = Date.now() + +function write(event) { + fs.appendFileSync( + LOG_PATH, + `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n` + ) +} + +function issueKey(kind, scenario, detail) { + return `${kind}|${scenario}|${JSON.stringify(detail).slice(0, 600)}` +} + +function recordIssue(kind, scenario, detail, severity = 'suspect') { + const key = issueKey(kind, scenario, detail) + const existing = issues.get(key) + if (existing) { + existing.count += 1 + existing.lastAt = new Date().toISOString() + write({ type: 'issue-repeat', key, count: existing.count }) + return existing + } + + const issue = { + count: 1, + detail, + firstAt: new Date().toISOString(), + kind, + lastAction, + lastAt: new Date().toISOString(), + scenario, + severity, + } + issues.set(key, issue) + write({ type: 'issue', key, issue }) + console.log( + `[issue:${severity}] ${kind} ${scenario} ${JSON.stringify(detail).slice(0, 240)}` + ) + return issue +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitForUrl(url, timeoutMs = 60_000) { + const startedAt = Date.now() + let lastError = null + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url) + + if (response.ok) { + return + } + + lastError = new Error(`${url} returned ${response.status}`) + } catch (error) { + lastError = error + } + + await sleep(250) + } + + throw lastError ?? new Error(`${url} did not become ready`) +} + +async function startServer() { + if (!SHOULD_START_SERVER) { + return null + } + + try { + await waitForUrl(TARGET_URL, 1000) + return null + } catch { + // No existing local server is ready on the soak URL. + } + + const nextServer = spawn('bun', ['serve'], { + cwd: repoRoot, + env: { ...process.env, PORT }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + nextServer.stdout.on('data', (chunk) => process.stdout.write(chunk)) + nextServer.stderr.on('data', (chunk) => process.stderr.write(chunk)) + + await waitForUrl(TARGET_URL) + + return nextServer +} + +function rng(seed) { + let s = seed >>> 0 + + return () => { + s = (s * 1_664_525 + 1_013_904_223) >>> 0 + + return s / 0x1_00_00_00_00 + } +} + +function pick(rand, xs) { + return xs[Math.floor(rand() * xs.length)] +} + +async function getExistingPage(browser) { + const context = + browser.contexts()[0] ?? + (await browser.newContext({ viewport: { width: 1400, height: 900 } })) + if (process.env.SOAK_NEW_PAGE === '1') { + return await context.newPage() + } + + const pages = context.pages() + return ( + pages.find((candidate) => + candidate.url().includes('/examples/yjs-collaboration') + ) ?? + pages.find((candidate) => !candidate.isClosed()) ?? + (await context.newPage()) + ) +} + +async function navigate(reason) { + metrics.hardResets += 1 + write({ type: 'navigate', reason, url: TARGET_URL }) + await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }) + await page.locator('[data-test-id="yjs-peer-a-append"]').waitFor({ + timeout: 30_000, + }) + await page.waitForFunction( + () => document.querySelectorAll('[contenteditable="true"]').length === 4, + null, + { timeout: 30_000 } + ) + await sleep(ACTION_DELAY_MS) +} + +async function click(peer, action, scenario) { + lastAction = { action, peer, scenario } + const locator = page + .locator(`[data-test-id="yjs-peer-${peer}-${action}"]`) + .first() + if ((await locator.count()) === 0) { + recordIssue('missing-control', scenario, { peer, action }, 'error') + return false + } + if (await locator.isDisabled()) { + metrics.skippedDisabled += 1 + write({ type: 'skip-disabled', peer, action, scenario }) + await sleep(ACTION_DELAY_MS) + return false + } + + await locator.scrollIntoViewIfNeeded() + await locator.click({ timeout: 5000 }) + metrics.actions += 1 + write({ type: 'action', peer, action, scenario }) + await sleep(ACTION_DELAY_MS) + return true +} + +async function connectAll(scenario) { + for (const peer of PEERS) { + await click(peer, 'connect', scenario) + } + await click('a', 'reconcile', scenario) +} + +async function snapshot() { + return await page.evaluate(() => { + const roots = Array.from( + document.querySelectorAll('[contenteditable="true"]') + ) + const peers = roots.map((root, index) => { + const blocks = Array.from( + root.querySelectorAll(':scope > [data-slate-node="element"]') + ).map((el) => ({ + childElementCount: el.querySelectorAll('[data-slate-node="element"]') + .length, + path: el.getAttribute('data-slate-path'), + tag: el.tagName, + text: el.textContent, + })) + + return { + blocks, + index, + text: root.textContent, + } + }) + + return { + bodyText: document.body.textContent?.slice(0, 2000) ?? '', + cursorTexts: Array.from( + document.querySelectorAll('[data-test-id$="-cursors"]') + ).map((el) => el.textContent), + editorCount: roots.length, + nestedDivInP: document.querySelectorAll('[contenteditable="true"] p div') + .length, + nestedParagraphCount: document.querySelectorAll( + '[contenteditable="true"] p p' + ).length, + peers, + title: document.title, + url: location.href, + } + }) +} + +function blockTexts(snap) { + return snap.peers.map((peer) => peer.blocks.map((block) => block.text)) +} + +function sameJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right) +} + +async function checkShape(scenario, label) { + const snap = await snapshot() + if (snap.editorCount !== 4) { + recordIssue( + 'editor-count', + scenario, + { label, editorCount: snap.editorCount }, + 'error' + ) + } + if (snap.nestedParagraphCount > 0) { + recordIssue( + 'nested-paragraph', + scenario, + { + label, + nestedParagraphCount: snap.nestedParagraphCount, + blocks: blockTexts(snap), + }, + 'error' + ) + } + if (snap.nestedDivInP > 0) { + recordIssue( + 'nested-div-in-paragraph', + scenario, + { label, nestedDivInP: snap.nestedDivInP, blocks: blockTexts(snap) }, + 'error' + ) + } + return snap +} + +async function expectConverged(scenario, label, expected = null) { + const snap = await checkShape(scenario, label) + const texts = blockTexts(snap) + const first = texts[0] + const converged = texts.every((candidate) => sameJson(candidate, first)) + + if (!converged) { + recordIssue('non-convergence', scenario, { label, texts }, 'error') + } + if (expected && !sameJson(first, expected)) { + recordIssue( + 'unexpected-document', + scenario, + { expected, label, texts }, + 'error' + ) + } + return snap +} + +async function runScenario(name, fn) { + metrics.iterations += 1 + metrics.scenarios[name] = (metrics.scenarios[name] ?? 0) + 1 + write({ type: 'scenario-start', name }) + try { + await fn(name) + } catch (error) { + recordIssue( + 'scenario-exception', + name, + { + message: error?.message ?? String(error), + stack: String(error?.stack ?? '').slice(0, 2000), + }, + 'error' + ) + await navigate(`recover:${name}`) + } finally { + write({ type: 'scenario-end', name }) + } +} + +async function scenarioBaselineSplit(name) { + await navigate(name) + await click('b', 'split-node', name) + await expectConverged(name, 'after b split', ['Hello ', 'world!']) +} + +async function scenarioOfflineUndoRemoteSplit(name) { + await navigate(name) + await click('a', 'disconnect', name) + await click('a', 'split-node', name) + await click('a', 'undo', name) + await click('b', 'split-node', name) + await click('a', 'connect', name) + await expectConverged(name, 'after reconnect', ['Hello ', 'world!']) +} + +async function scenarioOfflineUndoRemoteSplitRedo(name) { + await navigate(name) + await click('a', 'disconnect', name) + await click('a', 'split-node', name) + await click('a', 'undo', name) + await click('b', 'insert-text', name) + await click('b', 'split-node', name) + await click('a', 'connect', name) + await click('a', 'redo', name) + await expectConverged(name, 'after redo', ['Hello ', 'world!!']) +} + +async function scenarioSplitMergeLoop(name) { + await navigate(name) + for (let i = 0; i < 5; i += 1) { + await click('a', 'split-node', name) + await checkShape(name, `split ${i}`) + await click('a', 'merge-node', name) + await checkShape(name, `merge ${i}`) + } + await connectAll(name) + await expectConverged(name, 'after split merge loop') +} + +async function scenarioOfflineStructuralMix(name, seed) { + await navigate(`${name}:${seed}`) + const rand = rng(seed) + const offlinePeer = pick(rand, PEERS) + await click(offlinePeer, 'disconnect', name) + + const localActions = [ + 'wrap-node', + 'insert-fragment', + 'delete-fragment', + 'move-down', + 'set-node', + 'unset-node', + 'split-node', + 'merge-node', + 'undo', + 'redo', + ] + const remoteActions = [ + 'append', + 'insert-text', + 'split-node', + 'merge-node', + 'move', + 'wrap-node', + 'unwrap', + 'lift', + 'delete-backward', + ] + + for (let i = 0; i < 8; i += 1) { + const peer = + i % 2 === 0 + ? offlinePeer + : pick( + rand, + PEERS.filter((p) => p !== offlinePeer) + ) + const action = + peer === offlinePeer + ? pick(rand, localActions) + : pick(rand, remoteActions) + await click(peer, action, name) + await checkShape(name, `mix ${seed}.${i}`) + } + + await click(offlinePeer, 'connect', name) + await connectAll(name) + await expectConverged(name, `after structural mix ${seed}`) +} + +async function scenarioRandomControl(name, seed) { + await navigate(`${name}:${seed}`) + const rand = rng(seed) + for (let i = 0; i < 14; i += 1) { + await click(pick(rand, PEERS), pick(rand, ACTIONS), name) + await checkShape(name, `random ${seed}.${i}`) + } + await connectAll(name) + await expectConverged(name, `after random ${seed}`) +} + +async function scenarioAwareness(name) { + await navigate(name) + await click('a', 'select', name) + const selected = await snapshot() + const cursorTexts = selected.cursorTexts.join(' | ') + if (!LOCAL_CLIENT_CURSOR_RE.test(cursorTexts)) { + recordIssue('awareness-missing-selection', name, { cursorTexts }, 'suspect') + } + + await click('a', 'disconnect', name) + const offline = await snapshot() + const offlineCursorTexts = offline.cursorTexts.join(' | ') + if (LOCAL_CLIENT_CURSOR_RE.test(offlineCursorTexts)) { + recordIssue( + 'awareness-stale-offline-selection', + name, + { offlineCursorTexts }, + 'suspect' + ) + } + + await click('a', 'connect', name) + await click('a', 'select', name) + const reselected = await snapshot() + const reselectedCursorTexts = reselected.cursorTexts.join(' | ') + if (!LOCAL_CLIENT_CURSOR_RE.test(reselectedCursorTexts)) { + recordIssue( + 'awareness-missing-after-reconnect', + name, + { reselectedCursorTexts }, + 'suspect' + ) + } +} + +function writeSummary(final = false) { + const elapsedMs = Date.now() - startedAt + const sortedIssues = [...issues.values()].sort((a, b) => { + const order = { error: 0, suspect: 1, warning: 2 } + return (order[a.severity] ?? 9) - (order[b.severity] ?? 9) + }) + + const lines = [ + '# Yjs Collaboration Soak', + '', + `- status: ${final ? 'complete' : 'running'}`, + `- url: ${TARGET_URL}`, + `- run_id: ${RUN_ID}`, + `- elapsed_ms: ${elapsedMs}`, + `- actions: ${metrics.actions}`, + `- iterations: ${metrics.iterations}`, + `- hard_resets: ${metrics.hardResets}`, + `- skipped_disabled: ${metrics.skippedDisabled}`, + `- console_errors: ${metrics.consoleErrors}`, + `- page_errors: ${metrics.pageErrors}`, + `- issues: ${sortedIssues.length}`, + `- log: ${LOG_PATH}`, + '', + '## Scenario Counts', + '', + ...Object.entries(metrics.scenarios).map( + ([name, count]) => `- ${name}: ${count}` + ), + '', + '## Issues', + '', + ...(sortedIssues.length === 0 + ? ['None recorded yet.'] + : sortedIssues.map((issue, index) => + [ + `### ${index + 1}. ${issue.kind}`, + '', + `- severity: ${issue.severity}`, + `- scenario: ${issue.scenario}`, + `- count: ${issue.count}`, + `- first_at: ${issue.firstAt}`, + `- last_at: ${issue.lastAt}`, + `- last_action: ${JSON.stringify(issue.lastAction)}`, + `- detail: ${JSON.stringify(issue.detail)}`, + '', + ].join('\n') + )), + '', + ] + + fs.writeFileSync(SUMMARY_PATH, `${lines.join('\n')}\n`) +} + +async function main() { + write({ + type: 'start', + config: { + ACTION_DELAY_MS, + CDP, + DURATION_MS, + LOG_PATH, + OUTPUT_ROOT, + SHOULD_LAUNCH_BROWSER, + SHOULD_START_SERVER, + SUMMARY_PATH, + TARGET_URL, + }, + }) + + server = await startServer() + browser = SHOULD_LAUNCH_BROWSER + ? await chromium.launch({ headless: process.env.SOAK_HEADLESS === '1' }) + : await chromium.connectOverCDP(CDP) + page = await getExistingPage(browser) + + page.on('console', (msg) => { + const text = msg.text() + if (IGNORE_CONSOLE_RE.test(text)) { + write({ type: 'console-ignored', messageType: msg.type(), text }) + return + } + if (msg.type() === 'error' || ERROR_RE.test(text)) { + metrics.consoleErrors += 1 + recordIssue('console', 'page', { messageType: msg.type(), text }, 'error') + } else if (msg.type() === 'warning') { + write({ type: 'console-warning', text }) + } + }) + + page.on('pageerror', (error) => { + metrics.pageErrors += 1 + recordIssue('pageerror', 'page', { message: error.message }, 'error') + }) + + await navigate('initial') + + let seed = Number(process.env.SOAK_START_SEED ?? 1) + while (Date.now() - startedAt < DURATION_MS) { + await runScenario('baseline-split', scenarioBaselineSplit) + await runScenario( + 'offline-undo-remote-split', + scenarioOfflineUndoRemoteSplit + ) + await runScenario( + 'offline-undo-remote-split-redo', + scenarioOfflineUndoRemoteSplitRedo + ) + await runScenario('split-merge-loop', scenarioSplitMergeLoop) + await runScenario('awareness', scenarioAwareness) + await runScenario(`offline-structural-mix-${seed}`, (name) => + scenarioOfflineStructuralMix(name, seed) + ) + await runScenario(`random-control-${seed}`, (name) => + scenarioRandomControl(name, seed) + ) + + seed += 1 + + if (Date.now() - lastReportAt >= REPORT_EVERY_MS) { + writeSummary(false) + lastReportAt = Date.now() + console.log( + `[progress] elapsed=${Math.round((Date.now() - startedAt) / 1000)}s actions=${metrics.actions} iterations=${metrics.iterations} issues=${issues.size} summary=${SUMMARY_PATH}` + ) + } + } + + writeSummary(true) + write({ type: 'complete', metrics, issues: [...issues.values()] }) + console.log(`[complete] summary=${SUMMARY_PATH}`) + console.log(`[complete] log=${LOG_PATH}`) +} + +async function cleanup() { + if (SHOULD_LAUNCH_BROWSER && browser) { + await browser.close() + } + + if (server) { + server.kill() + } +} + +main() + .then(async () => { + await cleanup() + process.exit(0) + }) + .catch(async (error) => { + recordIssue( + 'runner-fatal', + 'main', + { + message: error?.message ?? String(error), + stack: String(error?.stack ?? '').slice(0, 4000), + }, + 'error' + ) + writeSummary(true) + console.error(error) + await cleanup() + process.exit(1) + }) diff --git a/tmp/yjs-collaboration-soak.mjs b/tmp/yjs-collaboration-soak.mjs new file mode 100644 index 0000000000..fc4d7c150c --- /dev/null +++ b/tmp/yjs-collaboration-soak.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env bun + +process.env.SOAK_OUTPUT_ROOT ??= 'tmp/yjs-collaboration-soak' + +console.warn( + '[yjs-collaboration-soak] tmp/yjs-collaboration-soak.mjs moved to scripts/proof/yjs-collaboration-soak.mjs; forwarding.' +) + +await import('../scripts/proof/yjs-collaboration-soak.mjs') From 3460a5f33c16ee3ca27b8036a510eeab52385446 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Thu, 11 Jun 2026 10:27:21 +0800 Subject: [PATCH 07/11] stable --- bun.lock | 43 + .../2026-06-09-yjs-hocuspocus-soak-repro.md | 286 ++ ...6-06-10-yjs-hocuspocus-known-issues-fix.md | 158 + .../2026-06-10-yjs-production-network-soak.md | 27 + ...-11-yjs-hocuspocus-persistent-room-soak.md | 59 + .../07-enabling-collaborative-editing.md | 56 + package.json | 9 + packages/slate-yjs/src/core/controller.ts | 7 + packages/slate-yjs/src/core/document.ts | 96 +- packages/slate-yjs/src/core/operations.ts | 182 +- .../test/document-id-contract.spec.ts | 33 + .../test/insert-fragment-contract.spec.ts | 23 + .../test/structural-soak-contract.spec.ts | 283 +- .../examples/yjs-collaboration.test.ts | 2697 ----------------- scripts/proof/yjs-collaboration-soak.mjs | 54 +- .../yjs-hocuspocus-persistent-room-soak.mjs | 667 ++++ .../proof/yjs-hocuspocus-production-soak.mjs | 722 +++++ scripts/yjs/hocuspocus-server.ts | 266 ++ site/constants/examples.ts | 1 + site/examples/ts/yjs-hocuspocus.tsx | 1532 ++++++++++ site/pages/examples/[example].tsx | 1 + 21 files changed, 4480 insertions(+), 2722 deletions(-) create mode 100644 docs/plans/2026-06-09-yjs-hocuspocus-soak-repro.md create mode 100644 docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md create mode 100644 docs/plans/2026-06-10-yjs-production-network-soak.md create mode 100644 docs/plans/2026-06-11-yjs-hocuspocus-persistent-room-soak.md create mode 100644 packages/slate-yjs/test/document-id-contract.spec.ts delete mode 100644 playwright/integration/examples/yjs-collaboration.test.ts create mode 100644 scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs create mode 100644 scripts/proof/yjs-hocuspocus-production-soak.mjs create mode 100644 scripts/yjs/hocuspocus-server.ts create mode 100644 site/examples/ts/yjs-hocuspocus.tsx diff --git a/bun.lock b/bun.lock index b000413485..2277af5079 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,10 @@ "@changesets/cli": "^2.26.2", "@faker-js/faker": "^10.0.0", "@happy-dom/global-registrator": "^20.9.0", + "@hocuspocus/extension-logger": "3.4.0", + "@hocuspocus/extension-redis": "3.4.0", + "@hocuspocus/provider": "3.4.0", + "@hocuspocus/server": "3.4.0", "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", @@ -54,6 +58,7 @@ "tw-animate-css": "^1.4.0", "typescript": "6.0.3", "ultracite": "7.4.4", + "y-protocols": "1.0.7", "yjs": "13.6.30", }, }, @@ -183,10 +188,12 @@ "react": "^19.2.5", "slate": "workspace:*", "slate-history": "workspace:*", + "slate-react": "workspace:*", }, "peerDependencies": { "react": ">=19.2.0", "slate": ">=0.124.2", + "slate-react": ">=0.124.2", "yjs": "13.6.30", }, }, @@ -406,6 +413,16 @@ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": "20.19.39", "happy-dom": "20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], + "@hocuspocus/common": ["@hocuspocus/common@3.4.4", "", { "dependencies": { "lib0": "^0.2.87" } }, "sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA=="], + + "@hocuspocus/extension-logger": ["@hocuspocus/extension-logger@3.4.0", "", { "dependencies": { "@hocuspocus/server": "^3.4.0" } }, "sha512-rQUKw7WnKnpdxRI6y+KsjjrvKX2oBFhqZie43t4AqXP9sRgxrYwFFil949iY+HJD/SeH98Sjktars+fzK2fZoQ=="], + + "@hocuspocus/extension-redis": ["@hocuspocus/extension-redis@3.4.0", "", { "dependencies": { "@hocuspocus/server": "^3.4.0", "@sesamecare-oss/redlock": "^1.4.0", "ioredis": "^5.6.1", "kleur": "^4.1.4", "lodash.debounce": "^4.0.8" }, "peerDependencies": { "y-protocols": "^1.0.6", "yjs": "^13.6.8" } }, "sha512-UliGC0Bm7/6/G1J47+2TfT94KCBZpJuWtSnrrCXQJObhqIShUz99Pc+U4l4mMNNptkxQh8GwRKVYtrnsD2SDhw=="], + + "@hocuspocus/provider": ["@hocuspocus/provider@3.4.0", "", { "dependencies": { "@hocuspocus/common": "^3.4.0", "@lifeomic/attempt": "^3.0.2", "lib0": "^0.2.87", "ws": "^8.17.1" }, "peerDependencies": { "y-protocols": "^1.0.6", "yjs": "^13.6.8" } }, "sha512-SXiHk4I2n1BqX1KuXgNDHpgMXseWbCIsaXT/lGaFq+qCF5F92cAmIg4mUPUQ39L1ugKM6Hm7tX33X+Jsk7466g=="], + + "@hocuspocus/server": ["@hocuspocus/server@3.4.0", "", { "dependencies": { "@hocuspocus/common": "^3.4.0", "async-lock": "^1.3.1", "async-mutex": "^0.5.0", "kleur": "^4.1.4", "lib0": "^0.2.47", "ws": "^8.5.0" }, "peerDependencies": { "y-protocols": "^1.0.6", "yjs": "^13.6.8" } }, "sha512-ludVWFkos7FgOmdGxn5UxhV7H0K4mMly8mq+wXAK8fOuW+9vrjQDfd23tOOcQPbZ0aGyPC0FMmEZ1GsFpVArCg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -478,6 +495,8 @@ "@inquirer/type": ["@inquirer/type@4.0.6", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g=="], + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "5.1.2", "string-width-cjs": "npm:string-width@4.2.3", "strip-ansi": "7.2.0", "strip-ansi-cjs": "npm:strip-ansi@6.0.1", "wrap-ansi": "8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -492,6 +511,8 @@ "@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="], + "@lifeomic/attempt": ["@lifeomic/attempt@3.1.0", "", {}, "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "7.29.2", "@types/node": "12.20.55", "find-up": "4.1.0", "fs-extra": "8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "7.29.2", "@changesets/types": "4.1.0", "@manypkg/find-root": "1.1.0", "fs-extra": "8.1.0", "globby": "11.1.0", "read-yaml-file": "1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -754,6 +775,8 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@sesamecare-oss/redlock": ["@sesamecare-oss/redlock@1.4.0", "", { "peerDependencies": { "ioredis": ">=5" } }, "sha512-2z589R+yxKLN4CgKxP1oN4dsg6Y548SE4bVYam/R0kHk7Q9VrQ9l66q+k1ehhSLLY4or9hcchuF9/MhuuZdjJg=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@slate/yjs": ["@slate/yjs@workspace:packages/slate-yjs"], @@ -928,6 +951,10 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], + + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "7.29.2", "cosmiconfig": "7.1.0", "resolve": "1.22.12" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], @@ -988,6 +1015,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1050,6 +1079,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -1286,6 +1317,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1406,6 +1439,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], @@ -1618,6 +1653,10 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "4.0.0", "strip-indent": "3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1718,6 +1757,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], @@ -1876,6 +1917,8 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/docs/plans/2026-06-09-yjs-hocuspocus-soak-repro.md b/docs/plans/2026-06-09-yjs-hocuspocus-soak-repro.md new file mode 100644 index 0000000000..f41cdc93a5 --- /dev/null +++ b/docs/plans/2026-06-09-yjs-hocuspocus-soak-repro.md @@ -0,0 +1,286 @@ +# Yjs Hocuspocus Soak Reproductions + +## Preconditions + +- Start the Hocuspocus server: `bun start:yjs` +- Start the examples site on port 3100. +- Open target page: `http://localhost:3100/examples/yjs-hocuspocus` +- The page must render four editors: Peer A, Peer B, Peer C, Peer D. +- Each peer must expose these controls with `data-test-id="yjs-peer-${peer}-${action}"`: + `select`, `mark-bold`, `disconnect`, `connect`, `reconcile`, `undo`, + `redo`, `append`, `replace`, `remove-node`, `split-node`, `merge-node`, + `move-down`, `set-node`, `unset-node`, `wrap-node`, `unwrap`, `lift`, + `insert-fragment`, `delete-fragment`, `delete-backward`, `insert-text`, + `move`. + +## Full Soak Command + +```sh +SOAK_URL='http://localhost:3100/examples/yjs-hocuspocus' \ +SOAK_MS=10800000 \ +SOAK_ACTION_DELAY_MS=1000 \ +SOAK_REPORT_EVERY_MS=60000 \ +SOAK_RUN_ID=hocuspocus-3h-20260609-231052 \ +SOAK_START_SERVER=0 \ +bun scripts/proof/yjs-collaboration-soak.mjs +``` + +Run output: + +- Summary: `test-results/yjs-collaboration-soak/hocuspocus-3h-20260609-231052/summary.md` +- Event log: `test-results/yjs-collaboration-soak/hocuspocus-3h-20260609-231052/events.jsonl` +- Duration: 10,806,341 ms +- Actions: 9,228 +- Iterations: 980 +- Issues: 17 + +## Reproductions + +Each sequence starts from a fresh `http://localhost:3100/examples/yjs-hocuspocus?room=` page load. + +### Awareness Selection Missing + +Actions: + +```text +a:select +``` + +Observed cursor text: + +```text +remote:none | remote:none | remote:none | remote:none +``` + +### Random Control 5 Non-Convergence + +Actions: + +```text +a:redo -> a:remove-node -> c:insert-fragment -> b:wrap-node -> a:disconnect -> a:merge-node -> b:mark-bold -> a:remove-node -> b:delete-fragment -> d:insert-text -> d:insert-fragment -> d:unset-node -> c:move -> d:delete-backward -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[[" world!Ken fragment!Eve fragment"],[" world!Ken fragment!Eve fragment"],[" world!Ken fragment!Eve fragment"],[" world!Ken fragment!Eve fragmen"]] +``` + +### Random Control 31 Non-Convergence + +Actions: + +```text +a:insert-text -> a:move-down -> a:move-down -> c:append -> b:insert-fragment -> d:reconcile -> d:delete-fragment -> a:reconcile -> b:move-down -> c:split-node -> d:reconcile -> a:move -> c:reconcile -> c:delete-fragment -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["d","d","!! KenLin fragment","d","block 2"],["d","d","!! KenLin fragment","d","block 2"],["d"," world","!! KenLin fragment"," world","block 2"],["d","d","!! KenLin fragment","d","block 2"]] +``` + +### Random Control 35 Non-Convergence + +Actions: + +```text +a:delete-backward -> d:reconcile -> c:move-down -> b:undo -> b:unset-node -> c:undo -> d:wrap-node -> d:remove-node -> a:remove-node -> d:undo -> c:unwrap -> d:redo -> a:redo -> d:move -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["Hello world"],["Hello world"],["Hello world","block 2"],["Hello world"]] +``` + +### Random Control 41 Non-Convergence + +Actions: + +```text +b:reconcile -> d:wrap-node -> c:lift -> a:wrap-node -> b:connect -> b:move-down -> c:connect -> b:unset-node -> d:connect -> c:append -> d:unset-node -> d:wrap-node -> d:delete-fragment -> c:insert-text -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[[" world! Ken!"," world! Ken!"," world! Ken!"],[" world! Ken!"," world! Ken!"," world! Ken!"],[" world! Ken!"," world! Ken"," world! Ken"],[" world! Ken!"," world! Ken!"," world! Ken!"]] +``` + +### Random Control 42 Non-Convergence + +Actions: + +```text +b:insert-text -> c:wrap-node -> b:append -> b:split-node -> d:reconcile -> d:move -> c:remove-node -> c:insert-text -> a:connect -> d:disconnect -> c:merge-node -> c:unwrap -> d:remove-node -> a:remove-node -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["Hello wo"],["Hello wo"],["Hello wo","rld!! Lin!"],["Hello wo"]] +``` + +### Random Control 43 Page Error + +Actions: + +```text +b:merge-node -> b:unwrap -> a:move-down -> d:append -> b:insert-text -> b:wrap-node -> c:move-down -> d:set-node -> b:reconcile -> a:move-down -> a:redo -> a:insert-fragment +``` + +Observed page error: + +```text +Yjs parent is text at path 0.0 +``` + +### Random Control 58 Non-Convergence + +Actions: + +```text +b:replace -> c:move-down -> a:reconcile -> b:move-down -> a:connect -> a:move -> b:insert-text -> a:set-node -> c:wrap-node -> c:insert-text -> c:wrap-node -> b:delete-fragment -> d:disconnect -> c:split-node -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["anonical snapshot.!!","anonical s","anonical s","block 2"],["anonical snapshot.!!","anonical s","anonical s","block 2"],["anonical snapshot.!!","anonical snapshot.!!","anonical snapshot.!!","block 2"],["anonical snapshot.!!","anonical s","anonical s","block 2"]] +``` + +### Random Control 68 Non-Convergence + +Actions: + +```text +b:delete-backward -> b:wrap-node -> c:move -> c:connect -> a:move -> c:lift -> a:unset-node -> c:wrap-node -> a:insert-fragment -> c:connect -> c:move-down -> d:split-node -> d:lift -> c:move-down -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["Hello","Hello"," worldAda fragment","Hello"],["Hello","Hello"," worldAda fragment","Hello"],[" worldAda fragment","Hello","Hello"],["Hello","Hello"," worldAda fragment","Hello"]] +``` + +### Random Control 75 Non-Convergence + +Actions: + +```text +b:insert-text -> a:delete-backward -> b:undo -> c:insert-fragment -> c:disconnect -> d:split-node -> d:unwrap -> a:move -> a:delete-backward -> d:merge-node -> b:redo -> c:unset-node -> d:delete-fragment -> d:merge-node -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["!Ken fragmenHello ",""],["!Ken fragmenHello ",""],["!Ken fragmenHello ",""],["!Ken fragmenHello "]] +``` + +### Random Control 85 Non-Convergence + +Actions: + +```text +b:reconcile -> a:move-down -> a:merge-node -> d:replace -> c:move -> b:connect -> c:move-down -> b:split-node -> c:unset-node -> d:append -> b:delete-fragment -> b:insert-fragment -> d:remove-node -> d:move-down -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["Lin fragment","Lin fragment","Lin fragment","Eve canonical snapshot."],["Lin fragment","Lin fragment","Lin fragment","Eve canonical snapshot."],["Lin fragment","Lin fragment","Lin fragment","Eve canonical snapshot."],["Eve canonical snapshot.","Lin fragment"]] +``` + +### Random Control 91 Page Error + +Actions: + +```text +b:replace -> a:append -> a:mark-bold -> c:append -> d:move-down -> a:insert-text -> c:replace -> d:connect -> c:undo -> c:insert-fragment -> b:redo -> b:move-down -> c:move -> c:delete-backward +``` + +Observed page error: + +```text +No Yjs node at path 0.1 +``` + +### Offline Structural Mix 99 Non-Convergence + +Actions: + +```text +b:disconnect -> b:delete-fragment -> c:split-node -> b:move-down -> c:append -> b:move-down -> d:split-node -> b:merge-node -> c:split-node -> b:connect -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["block 2","","llo"," Ken","world!"],["\uFEFFblock 2","","llo"," Ken","world!"],["block 2","","llo"," Ken","world!"],["block 2","","llo"," Ken","world!"]] +``` + +## 2026-06-10 Hocuspocus 3h Rerun + +Command: + +```sh +SOAK_URL='http://localhost:3100/examples/yjs-hocuspocus' \ +SOAK_MS=10800000 \ +SOAK_ACTION_DELAY_MS=1000 \ +SOAK_REPORT_EVERY_MS=60000 \ +SOAK_RUN_ID=hocuspocus-3h-restart-20260610-192303 \ +SOAK_START_SERVER=0 \ +bun scripts/proof/yjs-collaboration-soak.mjs +``` + +Run output: + +- Summary: `test-results/yjs-collaboration-soak/hocuspocus-3h-restart-20260610-192303/summary.md` +- Event log: `test-results/yjs-collaboration-soak/hocuspocus-3h-restart-20260610-192303/events.jsonl` +- Duration: 10,853,717 ms +- Actions: 7,128 +- Iterations: 756 +- Issues: 1 + +### Offline Undo Remote Split Redo Page Error + +Actions: + +```text +a:disconnect -> a:split-node -> a:undo -> b:insert-text -> b:split-node -> a:connect -> a:redo +``` + +Observed page error: + +```text +Cannot redo split_node because the right text is no longer at the split boundary. +``` + +### Random Control 116 Non-Convergence + +Actions: + +```text +b:move-down -> a:reconcile -> c:insert-fragment -> b:connect -> c:unwrap -> b:merge-node -> d:lift -> b:set-node -> b:insert-text -> a:replace -> c:remove-node -> a:set-node -> c:replace -> c:unset-node -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["Ken canonical snapshot.",""],["Ken canonical snapshot.",""],["Ken canonical snapshot."],["Ken canonical snapshot.",""]] +``` + +### Random Control 131 Non-Convergence + +Actions: + +```text +b:merge-node -> c:replace -> c:set-node -> d:mark-bold -> a:split-node -> a:delete-backward -> c:set-node -> d:set-node -> d:lift -> b:move-down -> a:unwrap -> a:mark-bold -> b:merge-node -> b:split-node -> a:connect -> b:connect -> c:connect -> d:connect -> a:reconcile +``` + +Observed peer block texts: + +```json +[["n"," canonical snapshot.K",""],["n"," canonical snapshot.K"],["n"," canonical snapshot.K",""],["n"," canonical snapshot.K",""]] +``` diff --git a/docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md b/docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md new file mode 100644 index 0000000000..fa597bc778 --- /dev/null +++ b/docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md @@ -0,0 +1,158 @@ +# Yjs Hocuspocus Known Issues Fix + +Objective: +Fix known Hocuspocus/Yjs collaboration issues; done when focused TDD repros and Hocuspocus soak gates pass; plan docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md. + +Flow mode: +one-shot execution + +Goal plan: +docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md + +Primary template: +manual task plan, because the autogoal helper requires an AGENTS.md repo root and slate-v2 has none. + +Applied packs: +- browser + +Completion threshold: +- Every known issue class from `test-results/yjs-collaboration-soak/hocuspocus-3h-20260609-231052/summary.md` has a focused failing TDD repro before implementation or a recorded N/A reason. +- Focused TDD repros pass after implementation. +- `test:yjs-hocuspocus-production-soak` completes with `PRODUCTION_SOAK_FAIL_ON_ISSUES=1` and no non-expected issues. +- A focused replay or soak path covers the old Hocuspocus issue classes: non-convergence, `Yjs parent is text at path 0.0`, `No Yjs node at path 0.1`, and awareness selection. +- `bun typecheck:site` or package-level typecheck covering modified TypeScript passes. +- `node /Users/felixfeng/Desktop/repos/plate-copy/.agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md` passes. + +Verification surface: +- Unit/contract tests under `packages/slate-yjs/test`. +- Browser/soak scripts under `scripts/proof`. +- Existing Hocuspocus example route `/examples/yjs-hocuspocus`. +- Summary artifacts under `test-results/yjs-*`. + +Constraints: +- Use TDD vertical slices: one failing behavior proof, one implementation fix, repeat. +- Do not change Hocuspocus server/provider product simulation unless the test harness itself is wrong. +- Do not claim awareness is fixed unless a deterministic browser or contract proof observes remote selection. +- Do not mark offline network console errors as product issues when they are expected browser offline noise. +- Do not run git status or branch hygiene. +- Do not commit, push, or open PR. + +Boundaries: +- Allowed: `packages/slate-yjs/**`, `site/examples/ts/yjs-hocuspocus.tsx`, `scripts/proof/**`, `package.json`, `docs/plans/**`. +- Avoid: unrelated Plate app/docs, release/publish/changelog, package export changes unless proven necessary. + +Output budget strategy: +- Read exact summary slices and focused source files only. +- Use `rg --files`, `rg -n`, and bounded `sed -n` ranges. +- Keep long soak output in `test-results/**` artifacts and inspect summaries. +- Avoid streaming full event logs unless a narrow event range is needed. + +Blocked condition: +- Stop only if a required Hocuspocus/browser dependency cannot run locally after three distinct attempts, or if fixing requires a public API/protocol decision that cannot be inferred from Slate/Yjs contracts. + +Start Gates: +| Gate | Applies | Evidence | +| --- | --- | --- | +| User requirements extracted | yes | User requested `autogoal`, TDD, fix all known issues, including `test-results/yjs-collaboration-soak/hocuspocus-3h-20260609-231052/summary.md`. | +| Existing goal checked | yes | `get_goal` returned no active goal. | +| Plan helper attempted | yes | `create-goal-scratchpad.mjs` failed because slate-v2 has no AGENTS.md root; manual equivalent plan created. | +| Source of known issues read | yes | Read old 3h summary and latest production soak summary. | +| TDD skill read | yes | Read `/Users/felixfeng/Desktop/repos/plate-copy/.agents/skills/tdd/SKILL.md`. | + +Work Checklist: +- [x] Extract explicit requirements and known issue classes with evidence. +- [x] Map old 17 issues to minimal behavior classes and existing test files. +- [x] RED: add focused repros for remote text-boundary deletion and virtual merge placeholder leakage. +- [x] GREEN: fix generated virtual ids, virtual unwrap/move placeholder consumption, cross-boundary `remove_text`, and merge placeholder cleanup. +- [x] Repeat RED/GREEN for remaining issue classes. +- [x] Add harness behavior for Next dev overlay click interception and expected browser offline noise. +- [x] Verify awareness selection through every old-seed Hocuspocus replay window. +- [x] Run focused package tests. +- [x] Run production Hocuspocus soak with fail-on-issues. +- [x] Run relevant typecheck/lint gate. +- [x] Review changed code for correctness and scope. +- [x] Run autogoal completion checker after this evidence update. + +Completion Gates: +| Gate | Applies | Required action | Evidence | +| --- | --- | --- | --- | +| TDD red proof | yes | Record failing test command/output before fix for each root issue class. | `bun test packages/slate-yjs/test/insert-fragment-contract.spec.ts` failed before `remove_text` projection with peer 0 retaining `alphaLin fragment`; `bun test packages/slate-yjs/test/structural-soak-contract.spec.ts` failed before merge placeholder cleanup for seeds 75, 116, and 131 with trailing empty blocks. | +| Focused tests | yes | Focused package/browser tests pass. | `bun test packages/slate-yjs/test/document-id-contract.spec.ts packages/slate-yjs/test/insert-fragment-contract.spec.ts packages/slate-yjs/test/structural-soak-contract.spec.ts packages/slate-yjs/test/merge-node-contract.spec.ts packages/slate-yjs/test/split-merge-contract.spec.ts packages/slate-yjs/test/unwrap-nodes-contract.spec.ts packages/slate-yjs/test/move-node-contract.spec.ts packages/slate-yjs/test/simple-operations-contract.spec.ts` passed: 60 pass, 0 fail. | +| Hocuspocus soak | yes | Production-like soak passes with fail-on-issues. | `PRODUCTION_SOAK_FAIL_ON_ISSUES=1 PRODUCTION_SOAK_RUN_ID=known-issues-final-production-20260610-1 bun ./scripts/proof/yjs-hocuspocus-production-soak.mjs` exited 0; summary reports actions 30, hard_reloads 2, browser_offline_windows 2, issues 0. | +| Old issue coverage | yes | Summary issue classes mapped to tests or N/A rows. | Old failing seed windows reran with issues 0: 1, 31, 41, 58, 68, 75, 85, 91, 99, 116, 131 under run ids `known-issues-all-seeds-*-20260610-1`. | +| Typecheck/lint | yes | Run owning package/site typecheck and formatter if files changed. | `bunx biome check --write ...` passed; `bun --filter @slate/yjs typecheck` passed; `bun typecheck:site` passed. | +| Autoreview | yes | Review changed code for bugs, regressions, missing tests. | Manual review found the remaining `offline-structural-mix-99` browser mismatch was DOM `\uFEFF` snapshot noise, not document divergence; runner now normalizes it. No broader API or protocol change added. | +| Goal plan complete | yes | Run check-complete after final evidence is recorded. | `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-06-10-yjs-hocuspocus-known-issues-fix.md` passed from `/Users/felixfeng/Desktop/repos/plate-copy` against the control-plane mirror plan. | + +Issue Class Map: +| Source issue | Class | Status | Evidence | +| --- | --- | --- | --- | +| random-control-5 | cross-Yjs-text-boundary `remove_text` was not broadcast to remotes | fixed | Added `insert_fragment` contract for delete at preserved text boundary; fixed `deleteYjsTextRange`; old seed 1 window now issues 0. | +| random-control-31,35,41,42,43,58,68,85,91 | structural virtual placeholder and unwrap/move leakage | fixed | Added structural contracts for unwrap and move placeholder consumption; old seed windows 31, 41, 58, 68, 85, and 91 now issues 0. | +| random-control-75,116,131 | element `merge_node` consumed children but left parent virtual placeholder exposing hidden target as an empty block | fixed | Added structural seed contracts; fixed merge fallback to consume the target placeholder; old seed windows 75, 116, and 131 now issues 0. | +| offline-structural-mix-99 | DOM `\uFEFF` zero-width text made equal document values compare unequal | fixed as harness noise | Snapshot normalization removes `\uFEFF`; seed 99 window now issues 0. | +| random-control-43 | path type error | fixed by structural placeholder changes | Old seed 41 window covers 43 and reports page_errors 0, issues 0. | +| random-control-91 | missing Yjs node | fixed by structural placeholder changes | Old seed 91 window reports page_errors 0, issues 0. | +| awareness | remote selection missing | covered | Every old-seed replay window includes repeated awareness scenarios and reports issues 0. | +| production baseline/persistence/network/degraded | independent provider non-convergence and Y.XmlText errors | fixed/covered | Final production-like soak `known-issues-final-production-20260610-1` reports issues 0. | +| Next dev overlay click interception | harness issue | fixed | Click helper falls back through pointer dispatch when Next dev overlay intercepts button clicks. | + +Phase / pass table: +| Phase | Status | Evidence | Next | +| --- | --- | --- | --- | +| Scope and goal setup | complete | Summaries read; manual goal plan created after helper failure; active goal created. | Done. | +| Root-cause mapping | complete | 17 old issue rows deduped into text-boundary delete, virtual unwrap/move, virtual merge placeholder, DOM FEFF, and harness click/offline-noise classes. | Done. | +| TDD fix loop | complete | RED tests failed before fixes and focused tests now pass. | Done. | +| Soak verification | complete | Old seed sweep and production-like soak report issues 0. | Done. | +| Completion audit | complete | Biome, package typecheck, site typecheck, manual review, and control-plane autogoal checker complete. | Done. | + +Findings: +- The old 3h Hocuspocus run completed 9,228 actions over 980 iterations and reported 17 issues. +- The latest production-like Hocuspocus run completed 18 actions and reported 12 issues across independent browser contexts. +- Hard product errors collapsed into three core encoder bugs: generated virtual id collisions across browser bundles, text-boundary delete projection, and unconsumed virtual placeholders after unwrap/move/merge. +- Harness noise included Next dev overlay click interception, expected offline network console errors, and DOM `\uFEFF` zero-width text in snapshot comparison. + +Timeline: +- 2026-06-10 Read autogoal prompt and TDD skill. +- 2026-06-10 Read old Hocuspocus 3h summary and latest production soak summary. +- 2026-06-10 Autogoal helper failed in slate-v2 because no AGENTS.md repo root exists. +- 2026-06-10 Created manual goal plan. +- 2026-06-10 Added RED package contracts for generated virtual id collision, text-boundary delete, unwrap/move placeholder leakage, and merge placeholder leakage. +- 2026-06-10 Fixed core Yjs operation encoder and soak harness noise. +- 2026-06-10 Reran all old failing seed windows and production-like Hocuspocus soak with issues 0. + +Decisions and tradeoffs: +- Use one-shot execution because the user asked to start fixing, not to approve a proposal. +- Treat the 17 old summary issues as coverage requirements but dedupe by root issue class to avoid writing shallow tests for repeated symptoms. +- Treat `` click interception as harness-first unless evidence shows product UI breakage. +- Treat DOM `\uFEFF` as snapshot noise because Slate can render zero-width DOM text for editable positioning while the editor/Yjs document value stays clean. +- Keep implementation scoped to operation encoding and proof scripts; no collaboration protocol redesign was needed. + +Review fixes: +- `remove_text` now deletes across consecutive visible `Y.XmlText` siblings when Slate has normalized them into one logical text leaf. +- `move_node` and `merge_node` now consume virtual placeholders that used to continue exposing hidden targets. +- Virtual generated ids include the Y.Doc client id, so independent browser bundles do not collide on `slate-yjs-1`. +- Production and collaboration soak runners distinguish expected offline/browser/dev-overlay noise from product issues. + +Error attempts: +| Error / failed attempt | Count | Next different move | Resolution | +| --- | --- | --- | --- | +| `create-goal-scratchpad.mjs` could not find repo root containing AGENTS.md in slate-v2 | 1 | Create manual plan using the required autogoal sections. | Manual plan created. | + +Verification evidence: +- `bun test packages/slate-yjs/test/insert-fragment-contract.spec.ts` failed before fix: peer 0 retained `alphaLin fragment` when expected `alphaLin fragmen`; passes after fix. +- `bun test packages/slate-yjs/test/structural-soak-contract.spec.ts` failed before merge placeholder cleanup for seeds 75, 116, and 131; passes after fix. +- `bun test packages/slate-yjs/test/document-id-contract.spec.ts packages/slate-yjs/test/insert-fragment-contract.spec.ts packages/slate-yjs/test/structural-soak-contract.spec.ts packages/slate-yjs/test/merge-node-contract.spec.ts packages/slate-yjs/test/split-merge-contract.spec.ts packages/slate-yjs/test/unwrap-nodes-contract.spec.ts packages/slate-yjs/test/move-node-contract.spec.ts packages/slate-yjs/test/simple-operations-contract.spec.ts` passed: 60 pass, 0 fail. +- Old Hocuspocus issue replay windows passed with issues 0: `known-issues-all-seeds-1-20260610-1`, `known-issues-all-seeds-31-20260610-1`, `known-issues-all-seeds-41-20260610-1`, `known-issues-all-seeds-58-20260610-1`, `known-issues-all-seeds-68-20260610-1`, `known-issues-all-seeds-75-20260610-1`, `known-issues-all-seeds-85-20260610-1`, `known-issues-all-seeds-91-20260610-1`, `known-issues-all-seeds-99-20260610-1`, `known-issues-all-seeds-116-20260610-1`, `known-issues-all-seeds-131-20260610-1`. +- `PRODUCTION_SOAK_FAIL_ON_ISSUES=1 PRODUCTION_SOAK_RUN_ID=known-issues-final-production-20260610-1 bun ./scripts/proof/yjs-hocuspocus-production-soak.mjs` exited 0; summary reports actions 30, hard_reloads 2, browser_offline_windows 2, issues 0. +- `bunx biome check --write ...` passed. +- `bun --filter @slate/yjs typecheck` passed. +- `bun typecheck:site` passed. + +Reboot status: +| Where am I? | Where am I going? | What is the goal? | What learned? | What done? | +| --- | --- | --- | --- | --- | +| Completion audit | Final checker | Fix known Hocuspocus/Yjs issues under TDD | Real Hocuspocus exposed operation encoder bugs that local happy-path sync hid | Fixes landed and verified against focused contracts, old seed windows, and production-like soak | + +Open risks: +- None known for the listed issue rows after focused replay windows and production-like soak. A fresh multi-hour soak can still be run as an endurance gate, but it is not required to prove the old recorded failures. diff --git a/docs/plans/2026-06-10-yjs-production-network-soak.md b/docs/plans/2026-06-10-yjs-production-network-soak.md new file mode 100644 index 0000000000..62b37d1471 --- /dev/null +++ b/docs/plans/2026-06-10-yjs-production-network-soak.md @@ -0,0 +1,27 @@ +# Yjs Production Network Soak + +## Requirements + +- [x] Borrow Potion's production boundary: Hocuspocus server, document-like room identity, auth token support, persistence storage, reconnect lifecycle. +- [x] Borrow Tiptap's production boundary: independent providers/editors joined by the same room, not four peers in one React tree. +- [x] Simulate real online network shape: separate browser contexts, latency/jitter, browser-level offline windows, reload/reconnect. +- [x] Do not change collaboration implementation internals. +- [x] Produce runnable evidence: summary file, event log, scenario counts, issue counts, network profile, room, storage location. + +## Implementation + +- [x] Add single-peer rendering mode to `site/examples/ts/yjs-hocuspocus.tsx`. +- [x] Add `scripts/proof/yjs-hocuspocus-production-soak.mjs`. +- [x] Add a package script for the production-like soak. + +## Verification + +- [x] Run the new production soak for a short window against local app/server. +- [x] Confirm it uses isolated browser contexts and the Hocuspocus route. +- [x] Confirm it records network/reload/reconnect evidence. + +Evidence: + +- `bun typecheck:site` +- `SOAK_HEADLESS=1 PRODUCTION_SOAK_MS=8000 PRODUCTION_SOAK_ACTION_DELAY_MS=50 PRODUCTION_SOAK_JITTER_MS=20 PRODUCTION_SOAK_RUN_ID='production-smoke-20260610-rerun' PRODUCTION_SOAK_START_SERVERS=0 bun ./scripts/proof/yjs-hocuspocus-production-soak.mjs` +- Summary: `test-results/yjs-hocuspocus-production-soak/production-smoke-20260610-rerun/summary.md` diff --git a/docs/plans/2026-06-11-yjs-hocuspocus-persistent-room-soak.md b/docs/plans/2026-06-11-yjs-hocuspocus-persistent-room-soak.md new file mode 100644 index 0000000000..897422bb7c --- /dev/null +++ b/docs/plans/2026-06-11-yjs-hocuspocus-persistent-room-soak.md @@ -0,0 +1,59 @@ +# Yjs Hocuspocus Persistent Room Soak + +## Goal + +Run a one-hour Hocuspocus soak where four independent browser users edit the +same room continuously. The page is not reloaded between scenarios, the room is +not changed, and the document is expected to grow over time. + +## Command + +```sh +SOAK_HEADLESS=1 \ +PERSISTENT_SOAK_MS=3600000 \ +PERSISTENT_SOAK_ACTION_DELAY_MS=1000 \ +PERSISTENT_SOAK_REPORT_EVERY_MS=60000 \ +PERSISTENT_SOAK_RUN_ID=persistent-room-1h-20260611 \ +bun ./scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs +``` + +## Scenario + +- Four independent browser contexts join the same Hocuspocus room. +- Each peer opens only its own editor instance with `?peer=a|b|c|d`. +- The runner repeats these scenario groups without changing room: + - `growth-burst`: all peers append text and insert text. + - `block-growth`: peers split nodes and insert fragments. + - `structure-churn`: peers wrap, unwrap, move, and set/unset attributes. + - `offline-catchup`: one peer disconnects, edits locally, then reconnects. + - `history-interleave`: undo/redo is interleaved with remote edits. +- After each group, all peers are checked for convergence and DOM shape. + +## Bug Recording + +The runner records: + +- `pageerror` +- console errors +- non-convergence after reconnect/checkpoint +- nested paragraph or `div` inside paragraph DOM shape +- editor-count mismatch +- scenario exceptions + +## Results + +Run id: `persistent-room-1h-20260611-010423` + +- Summary: `test-results/yjs-hocuspocus-persistent-room-soak/persistent-room-1h-20260611-010423/summary.md` +- Event log: `test-results/yjs-hocuspocus-persistent-room-soak/persistent-room-1h-20260611-010423/events.jsonl` +- Status: complete +- Elapsed: 3,635,409 ms +- Actions: 3,232 +- Convergence checkpoints: 506 +- Offline catchup windows: 101 +- Final document size: 405 blocks / 7,385 chars on every peer +- Console errors: 0 +- Page errors: 0 +- Issues: 0 + +No bugs were recorded in this run. diff --git a/docs/walkthroughs/07-enabling-collaborative-editing.md b/docs/walkthroughs/07-enabling-collaborative-editing.md index fc73c7ed81..3b014335aa 100644 --- a/docs/walkthroughs/07-enabling-collaborative-editing.md +++ b/docs/walkthroughs/07-enabling-collaborative-editing.md @@ -223,6 +223,62 @@ Keeping those owners separate is what lets Plate, CRDT adapters, custom storage, and local-only editors share the same raw Slate substrate without making the editor object grow adapter-specific namespaces. +## Hocuspocus transport + +`@slate/yjs` accepts a `YjsProviderLike`. Hocuspocus stays at the application +edge: wrap its provider so `provider.document` is exposed as `doc`, then pass +that adapter to `createYjsExtension`. + +Run the local Hocuspocus server: + +```sh +bun start:yjs +``` + +Then run the examples site and open `/examples/yjs-hocuspocus`: + +```sh +bun serve +``` + +The server mirrors the Potion deployment shape without requiring Potion's +Prisma document table. It loads and stores binary Yjs snapshots under +`.tmp/yjs-documents` and writes a JSON debug view of the Slate value next to +each snapshot. + +```dotenv +SLATE_YJS_PORT=4444 +SLATE_YJS_HOST=0.0.0.0 +SLATE_YJS_PATH=/yjs +SLATE_YJS_TIMEOUT=10000 +SLATE_YJS_DEBOUNCE=2000 +SLATE_YJS_MAX_DEBOUNCE=10000 +SLATE_YJS_STORAGE_DIR=.tmp/yjs-documents + +NEXT_PUBLIC_SLATE_YJS_URL=ws://localhost:4444/yjs +NEXT_PUBLIC_SLATE_YJS_ROOM=slate-yjs-hocuspocus-demo +``` + +Redis is optional for local development and useful when scaling more than one +Hocuspocus instance: + +```dotenv +SLATE_YJS_REDIS_ENABLED=1 +SLATE_YJS_REDIS_HOST=127.0.0.1 +SLATE_YJS_REDIS_PORT=6379 +``` + +For local auth smoke tests, set a server token and pass the matching public +demo token: + +```dotenv +SLATE_YJS_AUTH_TOKEN=local-write-token +NEXT_PUBLIC_SLATE_YJS_TOKEN=local-write-token +``` + +Real products should mint short-lived tokens server-side. Do not ship a write +token as a public browser variable. + ## Extension helpers Extensions can add grouped `state` and `tx` helpers for higher-level behavior. diff --git a/package.json b/package.json index d2eb0f6f03..06a933c1ee 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "check:ci": "bun check && bun test:integration", "clean": "turbo --filter \"./packages/*\" clean && rimraf './site/{.next,out,node_modules}' --glob", "dev": "bun serve", + "dev:yjs": "bun --watch ./scripts/yjs/hocuspocus-server.ts", "fix": "bun lint:fix", "internal:release:next": "bun prerelease && changeset publish --tag next", "lint": "biome check . && eslint .", @@ -63,6 +64,7 @@ "release:publish:next": "changeset publish --tag next", "serve": "cd ./site && next dev -p 3100", "serve:playwright": "node ./scripts/serve-playwright.mjs", + "start:yjs": "bun ./scripts/yjs/hocuspocus-server.ts", "test": "bun test:bun && bun test:vitest", "test:bun": "bun test ./packages/slate/test ./packages/slate-history/test ./packages/slate-dom/test ./packages/slate-hyperscript/test ./packages/slate-browser/test/core && bun --filter ./packages/slate-layout test", "test:inspect": "bun test --inspect-brk", @@ -76,6 +78,8 @@ "test:mobile-device-proof": "bun ./scripts/proof/mobile-device-proof.mjs", "test:mobile-device-proof:raw": "SLATE_BROWSER_RAW_MOBILE_REQUIRED=1 bun ./scripts/proof/mobile-device-proof.mjs", "test:persistent-soak": "bun build:next && bun ./scripts/proof/persistent-browser-soak.mjs", + "test:yjs-hocuspocus-production-soak": "bun ./scripts/proof/yjs-hocuspocus-production-soak.mjs", + "test:yjs-hocuspocus-persistent-room-soak": "bun ./scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs", "test:yjs-collaboration-soak": "bun ./scripts/proof/yjs-collaboration-soak.mjs", "test:release-discipline": "bun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/public-field-hard-cut-contract.ts ./packages/slate/test/escape-hatch-inventory-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/leaf-lifecycle-contract.ts ./packages/slate/test/selection-rebase-contract.ts ./packages/slate/test/compat-alias-hard-cut-contract.ts ./packages/slate/test/editor-foundation-contract.ts ./packages/slate/test/core-benchmark-scripts-contract.ts ./packages/slate/test/release-scripts-contract.ts ./packages/slate-react/test/rendered-dom-shape-contract.tsx --bail 1", "test:release-proof": "bun test:release-discipline && bun --filter slate-browser test:proof && bun test:mobile-device-proof && bun test:persistent-soak", @@ -97,6 +101,10 @@ "@changesets/cli": "^2.26.2", "@faker-js/faker": "^10.0.0", "@happy-dom/global-registrator": "^20.9.0", + "@hocuspocus/extension-logger": "3.4.0", + "@hocuspocus/extension-redis": "3.4.0", + "@hocuspocus/provider": "3.4.0", + "@hocuspocus/server": "3.4.0", "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", @@ -141,6 +149,7 @@ "tw-animate-css": "^1.4.0", "typescript": "6.0.3", "ultracite": "7.4.4", + "y-protocols": "1.0.7", "yjs": "13.6.30" }, "packageManager": "bun@1.3.12" diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts index f1b39e3067..87e3d47d9f 100644 --- a/packages/slate-yjs/src/core/controller.ts +++ b/packages/slate-yjs/src/core/controller.ts @@ -22,6 +22,7 @@ import { getYjsParent, getYjsTextContent, readSlateValueFromYjs, + removeRedundantEmptyYjsTextNodes, removeYjsChild, replaceYjsChildren, SPLIT_UNDO_TEXT_ATTRIBUTE, @@ -92,6 +93,7 @@ export class YjsController { private readonly destroyProviderOnUnmount: boolean private readonly doc: Y.Doc private readonly editor: Editor + private readonly canonicalizeOrigin = {} private readonly historyOrigin = {} private readonly localOrigin = {} private readonly seedOrigin = {} @@ -168,6 +170,7 @@ export class YjsController { this.observer = (_events, transaction) => { if ( transaction.origin === this.localOrigin || + transaction.origin === this.canonicalizeOrigin || transaction.origin === this.seedOrigin || this.paused ) { @@ -980,6 +983,10 @@ export class YjsController { this.repairRemoteSplitAfterOfflineUndo() } + this.doc.transact(() => { + removeRedundantEmptyYjsTextNodes(this.root) + }, this.canonicalizeOrigin) + const children = readSlateValueFromYjs(this.root) this.traceEntries.push({ mode }) diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts index 382e8ff09b..403bf739b4 100644 --- a/packages/slate-yjs/src/core/document.ts +++ b/packages/slate-yjs/src/core/document.ts @@ -9,6 +9,7 @@ const VIRTUAL_CHILD_ID_ATTRIBUTE = 'slate:yjs-virtual-child-id' const VIRTUAL_PLACEHOLDER_ATTRIBUTE = 'slate:yjs-virtual-placeholder' let nextNodeId = 0 +const nodeIdScope = Math.random().toString(36).slice(2) export const getYjsLength = (node: Y.XmlElement | Y.XmlText) => (node as unknown as { length: number }).length @@ -205,6 +206,38 @@ export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => { : [{ children: [{ text: '' }], type: 'paragraph' }] } +export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement) => { + const visit = (parent: Y.XmlElement) => { + for (const child of getRawYjsChildren(parent)) { + if (child instanceof Y.XmlElement) { + visit(child) + } + } + + const visibleSlots = getYjsVisibleChildSlots(root, parent) + + if (visibleSlots.length <= 1) { + return + } + + for (let index = visibleSlots.length - 1; index >= 0; index--) { + const slot = visibleSlots[index]! + const child = slot.node + + if ( + slot.rawIndex >= 0 && + child instanceof Y.XmlText && + getYjsTextContent(child).length === 0 && + Object.keys(getAttributes(child)).length === 0 + ) { + parent.delete(slot.rawIndex, 1) + } + } + } + + visit(root) +} + const getUniformTextAttributes = (node: Y.XmlText) => { const delta = node.toDelta() let attributes: Record | undefined @@ -405,8 +438,11 @@ export const insertYjsChild = ( } export const setVirtualYjsUnwrapMove = ( + root: Y.XmlElement, target: Y.XmlElement | Y.XmlText, - wrapper: Y.XmlElement + wrapper: Y.XmlElement, + wrapperParent: Y.XmlElement, + wrapperIndex: number ) => { const nodeId = target.getAttribute(NODE_ID_ATTRIBUTE) @@ -419,7 +455,17 @@ export const setVirtualYjsUnwrapMove = ( removeAttribute(target, HIDDEN_ATTRIBUTE) removeAttribute(wrapper, VIRTUAL_CHILD_ID_ATTRIBUTE) - wrapper.setAttribute(HIDDEN_ATTRIBUTE, true as never) + + if (getRawYjsChildren(wrapper).length === 0) { + wrapper.setAttribute(HIDDEN_ATTRIBUTE, true as never) + } else { + insertYjsChild( + root, + wrapperParent, + wrapperIndex, + createVirtualYjsMovePlaceholder(target) + ) + } } export const isVirtualYjsChild = ( @@ -434,6 +480,32 @@ export const isVirtualYjsChild = ( ) } +export const removeYjsVirtualPlaceholderChild = ( + root: Y.XmlElement, + parent: Y.XmlElement, + index: number, + target: Y.XmlElement | Y.XmlText +) => { + const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] + + if (!visibleSlot || visibleSlot.rawIndex < 0 || visibleSlot.node !== target) { + return false + } + + const rawChild = getRawYjsChildren(parent)[visibleSlot.rawIndex] + + if ( + !(rawChild instanceof Y.XmlElement) || + !isVirtualYjsPlaceholder(rawChild) + ) { + return false + } + + parent.delete(visibleSlot.rawIndex, 1) + + return true +} + export const removeYjsChild = ( root: Y.XmlElement, parent: Y.XmlElement, @@ -442,12 +514,25 @@ export const removeYjsChild = ( ): 'hidden' | 'hidden-parent' | 'visible' => { const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] const rawChildren = getRawYjsChildren(parent) + const hiddenIndex = rawChildren.findIndex( + (child) => isHiddenYjsNode(child) && matchesSlateNode(child, slateNode) + ) if (visibleSlot) { if (visibleSlot.rawIndex === -1) { throw new Error('Cannot remove a virtual Yjs child from its parent.') } + if ( + slateNode && + !matchesSlateNode(visibleSlot.node, slateNode) && + hiddenIndex !== -1 + ) { + parent.delete(hiddenIndex, 1) + + return 'hidden' + } + if ( visibleSlot.node instanceof Y.XmlElement && hasHiddenYjsDescendant(visibleSlot.node) @@ -462,10 +547,6 @@ export const removeYjsChild = ( return 'visible' } - const hiddenIndex = rawChildren.findIndex( - (child) => isHiddenYjsNode(child) && matchesSlateNode(child, slateNode) - ) - if (hiddenIndex === -1) { throw new Error('No Yjs child to remove at the requested visible path.') } @@ -510,7 +591,8 @@ const ensureYjsNodeId = (node: Y.XmlElement | Y.XmlText) => { return currentId } - const nextId = `slate-yjs-${++nextNodeId}` + const scope = node.doc ? String(node.doc.clientID) : nodeIdScope + const nextId = `slate-yjs-${scope}-${++nextNodeId}` node.setAttribute(NODE_ID_ATTRIBUTE, nextId) diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index 5aafc7936f..a0cb9c9747 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -15,6 +15,7 @@ import { insertYjsChild, isVirtualYjsChild, removeYjsChild, + removeYjsVirtualPlaceholderChild, setVirtualYjsMove, setVirtualYjsUnwrapMove, } from './document' @@ -377,9 +378,119 @@ const getYjsTextForInsert = (root: Y.XmlElement, path: number[]) => { return materializeEmptyYjsText(root, path) } +const resolveYjsTextPoint = ( + root: Y.XmlElement, + path: number[], + offset: number +) => { + const target = getYjsNode(root, path) + + if (!(target instanceof Y.XmlText)) { + return target + } + + const { index, parent } = getYjsParent(root, path) + const children = getYjsVisibleChildren(root, parent) + let remainingOffset = offset + + for (let childIndex = index; childIndex < children.length; childIndex++) { + const child = children[childIndex] + + if (!(child instanceof Y.XmlText)) { + break + } + + const length = getYjsLength(child) + + if (remainingOffset <= length) { + return { childIndex, offset: remainingOffset, parent, text: child } + } + + remainingOffset -= length + } + + return null +} + +const deleteYjsTextRange = ( + root: Y.XmlElement, + path: number[], + offset: number, + length: number +) => { + const point = resolveYjsTextPoint(root, path, offset) + + if (!point) { + return + } + + if (point instanceof Y.XmlText || point instanceof Y.XmlElement) { + throw new Error('remove_text target is not a Y.XmlText.') + } + + let childIndex = point.childIndex + let deleteOffset = point.offset + let remainingLength = length + + while (remainingLength > 0) { + const child = getYjsVisibleChildren(root, point.parent)[childIndex] + + if (!(child instanceof Y.XmlText)) { + break + } + + const availableLength = getYjsLength(child) - deleteOffset + const deleteLength = Math.min(availableLength, remainingLength) + let removedEmptyText = false + + if (deleteLength > 0) { + child.delete(deleteOffset, deleteLength) + remainingLength -= deleteLength + removedEmptyText = removeRedundantEmptyYjsText( + root, + point.parent, + childIndex, + child + ) + } + + if (remainingLength > 0) { + if (!removedEmptyText) { + childIndex++ + } + deleteOffset = 0 + } + } +} + const isEmptyYjsText = (node: Y.XmlElement | Y.XmlText) => node instanceof Y.XmlText && getYjsTextContent(node).length === 0 +const hasYjsAttributes = (node: Y.XmlElement | Y.XmlText) => + Object.keys( + ( + node as unknown as { getAttributes(): Record } + ).getAttributes() + ).length > 0 + +const removeRedundantEmptyYjsText = ( + root: Y.XmlElement, + parent: Y.XmlElement, + index: number, + text: Y.XmlText +) => { + if (getYjsTextContent(text).length > 0 || hasYjsAttributes(text)) { + return false + } + if (getYjsVisibleChildren(root, parent).length <= 1) { + return false + } + + removeYjsChild(root, parent, index) + + return true +} + const getYjsElementType = (element: Y.XmlElement) => String(element.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? element.nodeName) @@ -461,13 +572,12 @@ export const applySlateOperationToYjs = ( return { mode: 'operation', operationType: operation.type } } case 'remove_text': { - const text = getYjsNode(root, operation.path) - - if (!(text instanceof Y.XmlText)) { - throw new Error('remove_text target is not a Y.XmlText.') - } - - text.delete(operation.offset, operation.text.length) + deleteYjsTextRange( + root, + operation.path, + operation.offset, + operation.text.length + ) return { mode: 'operation', operationType: operation.type } } @@ -606,6 +716,7 @@ export const applySlateOperationToYjs = ( ) } + removeYjsVirtualPlaceholderChild(root, parent, index, target) hideYjsNode(target) return { @@ -711,6 +822,7 @@ export const applySlateOperationToYjs = ( } case 'move_node': { const target = getYjsNodeIf(root, operation.path) + const sourceIndex = operation.path.at(-1) if (!target) { return { @@ -735,7 +847,18 @@ export const applySlateOperationToYjs = ( isVirtualYjsChild(target, sourceParent) && pathsEqual(operation.newPath, sourceParentPath) ) { - setVirtualYjsUnwrapMove(target, sourceParent) + const { index: wrapperIndex, parent: wrapperParent } = getYjsParent( + root, + sourceParentPath + ) + + setVirtualYjsUnwrapMove( + root, + target, + sourceParent, + wrapperParent, + wrapperIndex + ) return { fallback: 'virtual-unwrap-ref', @@ -755,18 +878,43 @@ export const applySlateOperationToYjs = ( throw new Error('move_node destination is missing an index.') } + if ( + sourceParent instanceof Y.XmlElement && + sourceParent === newParent && + sourceIndex !== undefined + ) { + removeYjsVirtualPlaceholderChild( + root, + sourceParent, + sourceIndex, + target + ) + } + const insertionIndex = newIndex const newParentChildren = getYjsVisibleChildren(root, newParent) if ( - newIndex === 0 && + insertionIndex === 0 && newParentChildren.length === 1 && isEmptyYjsText(newParentChildren[0]!) ) { removeYjsChild(root, newParent, 0) } - if (newIndex === 0 && getYjsLength(newParent) === 0) { + if (insertionIndex === 0 && getYjsLength(newParent) === 0) { setVirtualYjsMove(root, target, newParent) + if ( + sourceParent instanceof Y.XmlElement && + sourceParent !== newParent && + sourceIndex !== undefined + ) { + removeYjsVirtualPlaceholderChild( + root, + sourceParent, + sourceIndex, + target + ) + } return { fallback: 'virtual-move-ref', @@ -778,9 +926,21 @@ export const applySlateOperationToYjs = ( insertYjsChild( root, newParent, - newIndex, + insertionIndex, createVirtualYjsMovePlaceholder(target) ) + if ( + sourceParent instanceof Y.XmlElement && + sourceParent !== newParent && + sourceIndex !== undefined + ) { + removeYjsVirtualPlaceholderChild( + root, + sourceParent, + sourceIndex, + target + ) + } return { fallback: 'virtual-move-placeholder', diff --git a/packages/slate-yjs/test/document-id-contract.spec.ts b/packages/slate-yjs/test/document-id-contract.spec.ts new file mode 100644 index 0000000000..b9327340c0 --- /dev/null +++ b/packages/slate-yjs/test/document-id-contract.spec.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import * as Y from 'yjs' + +describe('@slate/yjs document id contract', () => { + it('keeps generated virtual node ids unique across isolated browser bundles', async () => { + const nonce = Date.now() + const first = await import(`../src/core/document.ts?first=${nonce}`) + const second = await import(`../src/core/document.ts?second=${nonce}`) + + const firstDoc = new Y.Doc() + firstDoc.clientID = 101 + const firstRoot = firstDoc.get('slate', Y.XmlElement) + const firstText = new Y.XmlText() + + firstRoot.insert(0, [firstText]) + first.createVirtualYjsMovePlaceholder(firstText) + + const secondDoc = new Y.Doc() + secondDoc.clientID = 202 + const secondRoot = secondDoc.get('slate', Y.XmlElement) + const secondParagraph = new Y.XmlElement('paragraph') + const secondWrapper = new Y.XmlElement('block-quote') + + secondRoot.insert(0, [secondWrapper, secondParagraph]) + second.setVirtualYjsMove(secondRoot, secondParagraph, secondWrapper) + + assert.notEqual( + firstText.getAttribute('slate:yjs-id'), + secondParagraph.getAttribute('slate:yjs-id') + ) + }) +}) diff --git a/packages/slate-yjs/test/insert-fragment-contract.spec.ts b/packages/slate-yjs/test/insert-fragment-contract.spec.ts index da0dc2db7e..cf014f8f58 100644 --- a/packages/slate-yjs/test/insert-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/insert-fragment-contract.spec.ts @@ -154,6 +154,29 @@ describe('@slate/yjs insert_fragment collaboration contract', () => { assertPeerTexts(peers, ['alphaLin fragment']) }) + it('broadcasts remove_text at the end of a preserved insert_fragment text boundary', () => { + const peers = createPeers(['a', 'b', 'c']) + const [, b] = peers + + insertFragment(b) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['alphaLin fragment']) + + const [text] = getParagraphTexts(b) + + b.editor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: text!.length }, + focus: { path: [0, 0], offset: text!.length }, + }) + tx.text.deleteBackward({ unit: 'character' }) + }) + syncConnectedPeers(peers) + + assertPeerTexts(peers, ['alphaLin fragmen']) + assertNoRootSnapshot(b) + }) + it('undoes and redoes only the local insert_fragment intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/structural-soak-contract.spec.ts b/packages/slate-yjs/test/structural-soak-contract.spec.ts index 34d1a4bc74..08280d806d 100644 --- a/packages/slate-yjs/test/structural-soak-contract.spec.ts +++ b/packages/slate-yjs/test/structural-soak-contract.spec.ts @@ -32,6 +32,13 @@ const appendTexts: Record = { d: ' Eve', } +const fragmentTexts: Record = { + a: 'Ada fragment', + b: 'Lin fragment', + c: 'Ken fragment', + d: 'Eve fragment', +} + const replacementTexts: Record = { a: 'Ada canonical snapshot.', b: 'Lin canonical snapshot.', @@ -167,6 +174,29 @@ const assertPeerParagraphTexts = ( } } +const assertFirstParagraphTextChildren = ( + peers: readonly Peer[], + expected: readonly string[] +) => { + for (const peer of peers) { + const [firstBlock] = editorValueOf(peer) + + assert.deepEqual( + hasChildren(firstBlock) + ? firstBlock.children.map((child) => child.text) + : [], + expected, + JSON.stringify(editorValueOf(peer)) + ) + assert.deepEqual( + readSlateValueFromYjs(getYjsState(peer).root())[0]?.children.map( + (child) => child.text + ), + expected + ) + } +} + const firstBlockIsQuote = (peer: Peer) => { const [firstBlock] = editorValueOf(peer) @@ -320,6 +350,37 @@ const runCommand = ( sync(peers) } +const runIncrementalCommand = ( + peers: Record, + peerId: PeerId, + command: (peer: Peer, peerId: PeerId) => void +) => { + const source = peers[peerId] + const stateVector = Y.encodeStateVector(source.doc) + + command(source, peerId) + + if (getYjsState(source).connected()) { + const update = Y.encodeStateAsUpdate(source.doc, stateVector) + + for (const target of allPeers(peers)) { + if (source === target || !getYjsState(target).connected()) { + continue + } + + Y.applyUpdate(target.doc, update, source) + } + } + + for (const peer of allPeers(peers)) { + if (!getYjsState(peer).connected()) { + continue + } + + runYjsUpdate(peer, (yjs) => yjs.reconcile()) + } +} + const setConnected = ( peers: Record, peerId: PeerId, @@ -478,6 +539,12 @@ const unsetFirstBlockRole = (peer: Peer) => { }) } +const setFirstBlockRole = (peer: Peer) => { + peer.editor.update((tx) => { + tx.nodes.set({ role: 'title' } as never, { at: [0] }) + }) +} + const insertExclamation = (peer: Peer) => { const entry = firstBlockTextEntry(peer, 'last') @@ -492,6 +559,22 @@ const insertExclamation = (peer: Peer) => { }) } +const insertPeerFragment = (peer: Peer, peerId: PeerId) => { + const entry = firstBlockTextEntry(peer, 'last') + + if (!entry) { + return + } + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, + }) + tx.fragment.insert([{ text: fragmentTexts[peerId] }]) + }) +} + const deleteFirstFragment = (peer: Peer) => { const entry = firstBlockTextEntry(peer, 'first') @@ -534,6 +617,32 @@ const reconcilePeer = (peer: Peer) => { runYjsUpdate(peer, (yjs) => yjs.reconcile()) } +const undoPeer = (peer: Peer) => { + runYjsUpdate(peer, (yjs) => yjs.undo()) +} + +const redoPeer = (peer: Peer) => { + runYjsUpdate(peer, (yjs) => yjs.redo()) +} + +const toggleFirstBlockBold = (peer: Peer) => { + const entry = firstBlockTextEntry(peer, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) + + peer.editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, + }) + tx.marks.toggle('bold') + }) +} + const assertDocumentHasTextBoundary = (peers: readonly Peer[]) => { for (const peer of peers) { const value = editorValueOf(peer) @@ -619,12 +728,148 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'c', unwrapFirstBlock) }) assertNoNestedParagraphs(allPeers(peers)) - assertPeerParagraphTexts([peers.a, peers.b, peers.c], ['Hello wo']) + assertPeerParagraphTexts( + [peers.a, peers.b, peers.c], + ['Hello wo', 'rld!! Lin!'] + ) assertPeerParagraphTexts([peers.d], ['Hello world!! Lin!']) setConnected(peers, 'd', true) - assertPeerParagraphTexts(allPeers(peers), ['Hello wo']) + assertPeerParagraphTexts(allPeers(peers), ['Hello wo', 'rld!! Lin!']) + }) + + it('keeps remote wrap after lift from duplicating the wrapped block', () => { + const peers = createAwarePeers() + + runCommand(peers, 'd', wrapFirstBlock) + runCommand(peers, 'c', liftFirstWrappedBlock) + runCommand(peers, 'a', wrapFirstBlock) + + assertPeerParagraphTexts(allPeers(peers), ['Hello world!']) + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + }) + + it('keeps repeated root moves from duplicating a virtual placeholder block', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', wrapFirstBlock) + runCommand(peers, 'c', moveFirstBlockDown) + runCommand(peers, 'd', (peer) => { + peer.editor.update((tx) => { + tx.nodes.set({ role: 'title' } as never, { at: [0] }) + }) + }) + runCommand(peers, 'a', moveFirstBlockDown) + + assertPeerParagraphTexts(allPeers(peers), ['Hello world!', 'block 2']) + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + }) + + it('keeps random-control seed 75 empty trailing block converged', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', insertExclamation) + runCommand(peers, 'a', deleteBackwardFromFirstBlockEnd) + runCommand(peers, 'b', undoPeer) + runCommand(peers, 'c', insertPeerFragment) + setConnected(peers, 'c', false) + runCommand(peers, 'd', splitFirstText) + runCommand(peers, 'd', unwrapFirstBlock) + runCommand(peers, 'a', moveFirstBlockAfterSecond) + runCommand(peers, 'a', deleteBackwardFromFirstBlockEnd) + runCommand(peers, 'd', mergeSecondBlock) + runCommand(peers, 'b', redoPeer) + runCommand(peers, 'c', unsetFirstBlockRole) + runCommand(peers, 'd', deleteFirstFragment) + runCommand(peers, 'd', mergeSecondBlock) + setConnected(peers, 'a', true) + setConnected(peers, 'b', true) + setConnected(peers, 'c', true) + setConnected(peers, 'd', true) + runCommand(peers, 'a', reconcilePeer) + + assertPeerParagraphTexts(allPeers(peers), ['!Ken fragmenHello ']) + }) + + it('keeps offline structural mix seed 99 from retaining a zero-width prefix', () => { + const peers = createAwarePeers() + + setConnected(peers, 'b', false) + runCommand(peers, 'b', deleteFirstFragment) + runCommand(peers, 'c', splitFirstText) + runCommand(peers, 'b', moveFirstBlockDown) + runCommand(peers, 'c', appendText) + runCommand(peers, 'b', moveFirstBlockDown) + runCommand(peers, 'd', splitFirstText) + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'c', splitFirstText) + setConnected(peers, 'b', true) + setConnected(peers, 'a', true) + setConnected(peers, 'b', true) + setConnected(peers, 'c', true) + setConnected(peers, 'd', true) + runCommand(peers, 'a', reconcilePeer) + + assertPeerParagraphTexts(allPeers(peers), [ + 'block 2', + 'llo', + ' Ken', + 'world!', + ]) + assertFirstParagraphTextChildren(allPeers(peers), ['block 2']) + }) + + it('keeps random-control seed 116 empty trailing block converged', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', moveFirstBlockDown) + runCommand(peers, 'a', reconcilePeer) + runCommand(peers, 'c', insertPeerFragment) + setConnected(peers, 'b', true) + runCommand(peers, 'c', unwrapFirstBlock) + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'd', liftFirstWrappedBlock) + runCommand(peers, 'b', setFirstBlockRole) + runCommand(peers, 'b', insertExclamation) + runCommand(peers, 'a', replaceDocument) + runCommand(peers, 'c', removeSecondBlock) + runCommand(peers, 'a', setFirstBlockRole) + runCommand(peers, 'c', replaceDocument) + runCommand(peers, 'c', unsetFirstBlockRole) + setConnected(peers, 'a', true) + setConnected(peers, 'b', true) + setConnected(peers, 'c', true) + setConnected(peers, 'd', true) + runCommand(peers, 'a', reconcilePeer) + + assertPeerParagraphTexts(allPeers(peers), ['Ken canonical snapshot.']) + }) + + it('keeps random-control seed 131 empty trailing block converged', () => { + const peers = createAwarePeers() + + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'c', replaceDocument) + runCommand(peers, 'c', setFirstBlockRole) + runCommand(peers, 'd', toggleFirstBlockBold) + runCommand(peers, 'a', splitFirstText) + runCommand(peers, 'a', deleteBackwardFromFirstBlockEnd) + runCommand(peers, 'c', setFirstBlockRole) + runCommand(peers, 'd', setFirstBlockRole) + runCommand(peers, 'd', liftFirstWrappedBlock) + runCommand(peers, 'b', moveFirstBlockDown) + runCommand(peers, 'a', unwrapFirstBlock) + runCommand(peers, 'a', toggleFirstBlockBold) + runCommand(peers, 'b', mergeSecondBlock) + runCommand(peers, 'b', splitFirstText) + setConnected(peers, 'a', true) + setConnected(peers, 'b', true) + setConnected(peers, 'c', true) + setConnected(peers, 'd', true) + runCommand(peers, 'a', reconcilePeer) + + assertPeerParagraphTexts(allPeers(peers), ['n', ' canonical snapshot.K']) }) it('keeps structural edits from projecting block placeholders inside paragraphs', () => { @@ -830,4 +1075,38 @@ describe('@slate/yjs structural soak contract', () => { assertDocumentHasTextBoundary(allPeers(peers)) assertPeerParagraphTexts(allPeers(peers), ['', 'l', 'o ', '', 'world!']) }) + + it('keeps remote wrap and unwrap from dropping split-merge text prefixes', () => { + const peers = createAwarePeers() + + runIncrementalCommand(peers, 'b', splitFirstText) + runIncrementalCommand(peers, 'a', appendText) + runIncrementalCommand(peers, 'c', insertExclamation) + runIncrementalCommand(peers, 'a', appendText) + runIncrementalCommand(peers, 'a', appendText) + runIncrementalCommand(peers, 'b', insertExclamation) + runIncrementalCommand(peers, 'c', splitFirstText) + runIncrementalCommand(peers, 'd', mergeSecondBlock) + + assertPeerParagraphTexts(allPeers(peers), [ + 'Hello Ada! Ada Ada!', + 'world!', + ]) + + runIncrementalCommand(peers, 'a', wrapFirstBlock) + + assertPeerParagraphTexts(allPeers(peers), [ + 'Hello Ada! Ada Ada!', + 'world!', + ]) + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + + runIncrementalCommand(peers, 'b', unwrapFirstBlock) + + assertPeerParagraphTexts(allPeers(peers), [ + 'Hello Ada! Ada Ada!', + 'world!', + ]) + assertNoElementDescendantsInsideParagraphs(allPeers(peers)) + }) }) diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts deleted file mode 100644 index 7b03e6b71f..0000000000 --- a/playwright/integration/examples/yjs-collaboration.test.ts +++ /dev/null @@ -1,2697 +0,0 @@ -import { expect, type Page, test } from '@playwright/test' - -import { openExample } from 'slate-browser/playwright' - -type PeerId = 'a' | 'b' | 'c' | 'd' -type YjsPeerControl = - | 'append' - | 'connect' - | 'delete-backward' - | 'delete-fragment' - | 'disconnect' - | 'insert-fragment' - | 'insert-text' - | 'lift' - | 'merge-node' - | 'move' - | 'move-down' - | 'reconcile' - | 'redo' - | 'remove-node' - | 'replace' - | 'set-node' - | 'split-node' - | 'undo' - | 'unset-node' - | 'unwrap' - | 'wrap-node' - -type YjsPeerAction = readonly [peer: PeerId, control: YjsPeerControl] - -const structuralBrowserErrorPattern = - /Slate point does not target a Y\.XmlText|Cannot get the leaf node|No Yjs node at path|Cannot descend into Y\.XmlText|Yjs parent is text|start text node|end text node|

cannot be a descendant of

|validateDOMNesting/i - -const byTestId = (page: Page, id: string) => - page.locator(`[data-test-id="${id}"]`) - -const peerSurface = (page: Page, peer: PeerId) => - page.locator(`#yjs-peer-${peer}-editor-surface`) - -const peerTextbox = (page: Page, peer: PeerId) => - peerSurface(page, peer).locator('[role="textbox"]') - -const selectFirstText = async (page: Page, peer: PeerId, length: number) => { - await selectPeerTextRange(page, peer, 0, 0, length) -} - -const getPeerParagraphTexts = (page: Page, peer: PeerId) => - peerTextbox(page, peer).evaluate((textbox) => - [...textbox.querySelectorAll('p')].map((paragraph) => { - const clone = paragraph.cloneNode(true) as HTMLElement - - clone - .querySelectorAll('[data-slate-placeholder="true"]') - .forEach((placeholder) => { - placeholder.remove() - }) - - return (clone.textContent ?? '').replaceAll('\uFEFF', '') - }) - ) - -const getPeerTopLevelBlockTexts = (page: Page, peer: PeerId) => - peerTextbox(page, peer).evaluate((textbox) => - [...textbox.children].map((block) => { - const clone = block.cloneNode(true) as HTMLElement - - clone - .querySelectorAll('[data-slate-placeholder="true"]') - .forEach((placeholder) => { - placeholder.remove() - }) - - return (clone.textContent ?? '').replaceAll('\uFEFF', '') - }) - ) - -const expectAllPeerParagraphTexts = async (page: Page, expected: string[]) => { - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect.poll(() => getPeerParagraphTexts(page, peer)).toEqual(expected) - } -} - -const expectAllPeerTopLevelBlockTexts = async ( - page: Page, - expected: string[] -) => { - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerTopLevelBlockTexts(page, peer)) - .toEqual(expected) - } -} - -const expectNoPeerBlockQuotes = async (page: Page) => { - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect(peerTextbox(page, peer).locator('blockquote')).toHaveCount(0) - } -} - -const expectNoPeerNestedParagraphs = async (page: Page) => { - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect(peerTextbox(page, peer).locator('p p')).toHaveCount(0) - } -} - -const expectNoPeerInvalidParagraphDescendants = async (page: Page) => { - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect( - peerTextbox(page, peer).locator('p p, p div, p blockquote') - ).toHaveCount(0) - } -} - -const watchStructuralBrowserErrors = (page: Page) => { - const errors: string[] = [] - - page.on('pageerror', (error) => { - errors.push(String(error.stack || error.message || error)) - }) - page.on('console', (message) => { - if (message.type() !== 'error') { - return - } - - const text = message.text() - - if (structuralBrowserErrorPattern.test(text)) { - errors.push(text) - } - }) - - return errors -} - -const clickPeerControl = async ( - page: Page, - peer: PeerId, - control: YjsPeerControl -) => { - const button = byTestId(page, `yjs-peer-${peer}-${control}`) - - await expect(button).toBeVisible() - - if (await button.isDisabled()) { - return false - } - - await button.click() - - return true -} - -const runPeerActions = async ( - page: Page, - actions: readonly YjsPeerAction[], - options: { errors?: readonly string[] } = {} -) => { - for (const [peer, control] of actions) { - await test.step(`${peer} ${control}`, async () => { - await clickPeerControl(page, peer, control) - await page.waitForTimeout(0) - if (options.errors) { - expect(options.errors, `${peer} ${control}`).toEqual([]) - } - }) - } -} - -const expectAllPeerTextboxesAlive = async (page: Page) => { - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect(peerTextbox(page, peer)).toBeVisible() - } -} - -const getPeerLayoutProof = (page: Page, peer: PeerId) => - peerTextbox(page, peer).evaluate((textbox) => { - const editorRect = textbox.getBoundingClientRect() - - return { - editorHeight: Math.round(editorRect.height), - paragraphs: [...textbox.querySelectorAll('p')].map((paragraph) => { - const rect = paragraph.getBoundingClientRect() - - return { - height: Math.round(rect.height), - text: paragraph.textContent ?? '', - } - }), - } - }) - -const getHistoryShortcuts = (page: Page) => - page.evaluate(() => - /Mac|iPhone|iPad/.test(navigator.platform) - ? { redo: 'Meta+Shift+Z', undo: 'Meta+Z' } - : { redo: 'Control+Shift+Z', undo: 'Control+Z' } - ) - -const replacePeerText = async ( - page: Page, - peer: PeerId, - paragraphs: string[] -) => { - await peerTextbox(page, peer).evaluate((textbox, nextParagraphs) => { - const handle = ( - textbox as HTMLElement & { - __slateBrowserHandle?: { - applyOperations: ( - operations: readonly Record[], - options?: Record - ) => void - } - } - ).__slateBrowserHandle - - if (!handle?.applyOperations) { - throw new Error('Peer editor does not expose Slate browser handle setup') - } - - const toParagraph = (text: string) => ({ - children: [{ text }], - type: 'paragraph', - }) - const currentParagraphs = [...textbox.querySelectorAll('p')].map( - (paragraph) => - (paragraph.textContent ?? '') - .replaceAll('\uFEFF', '') - .replaceAll('\n', '') - ) - const operations: Record[] = [] - const [currentFirst = ''] = currentParagraphs - const [nextFirst = ''] = nextParagraphs - - for (let index = currentParagraphs.length - 1; index > 0; index--) { - operations.push({ - node: toParagraph(currentParagraphs[index] ?? ''), - path: [index], - root: 'main', - type: 'remove_node', - }) - } - - if (currentFirst.length > 0) { - operations.push({ - offset: 0, - path: [0, 0], - root: 'main', - text: currentFirst, - type: 'remove_text', - }) - } - - if (nextFirst.length > 0) { - operations.push({ - offset: 0, - path: [0, 0], - root: 'main', - text: nextFirst, - type: 'insert_text', - }) - } - - nextParagraphs.slice(1).forEach((paragraph, index) => { - operations.push({ - node: toParagraph(paragraph), - path: [index + 1], - root: 'main', - type: 'insert_node', - }) - }) - - handle.applyOperations(operations, { tag: 'yjs-example-test-setup' }) - }, paragraphs) -} - -const placePeerCaret = async ( - page: Page, - peer: PeerId, - paragraphIndex: number, - offset: number -) => { - const position = await page.evaluate( - ({ offset, paragraphIndex, peer }) => { - const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) - const textbox = root?.querySelector('[role="textbox"]') - const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] - const textNode = paragraph - ? document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT).nextNode() - : null - - if (!textbox || !textNode) { - throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) - } - - const textboxRect = textbox.getBoundingClientRect() - const paragraphRect = paragraph!.getBoundingClientRect() - const toTextboxPoint = (x: number, y: number) => ({ - x: Math.max(1, Math.min(textboxRect.width - 1, x - textboxRect.left)), - y: Math.max(1, Math.min(textboxRect.height - 1, y - textboxRect.top)), - }) - - if (offset <= 0) { - return toTextboxPoint( - paragraphRect.left + 2, - paragraphRect.top + paragraphRect.height / 2 - ) - } - - const range = document.createRange() - const boundedOffset = Math.min(offset, textNode.textContent?.length ?? 0) - - range.setStart(textNode, 0) - range.setEnd(textNode, boundedOffset) - - const rect = range.getBoundingClientRect() - - return toTextboxPoint( - Math.max(paragraphRect.left + 2, rect.right + 1), - rect.top + rect.height / 2 - ) - }, - { offset, paragraphIndex, peer } - ) - - await peerTextbox(page, peer).click({ position }) -} - -const selectPeerTextRange = async ( - page: Page, - peer: PeerId, - paragraphIndex: number, - anchorOffset: number, - focusOffset: number -) => { - const modelRange = { - anchor: { path: [paragraphIndex, 0], offset: anchorOffset }, - focus: { path: [paragraphIndex, 0], offset: focusOffset }, - } - - if (anchorOffset === focusOffset) { - await placePeerCaret(page, peer, paragraphIndex, anchorOffset) - return - } - - await page.evaluate( - ({ anchorOffset, focusOffset, modelRange, paragraphIndex, peer }) => { - const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) - const textbox = root?.querySelector('[role="textbox"]') - const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] - - if (!textbox || !paragraph) { - throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) - } - - const findPoint = (offset: number) => { - const walker = document.createTreeWalker( - paragraph, - NodeFilter.SHOW_TEXT - ) - let seen = 0 - let textNode = walker.nextNode() - - while (textNode) { - const text = textNode.textContent ?? '' - const visibleText = text.replaceAll('\uFEFF', '') - - if (visibleText.length === 0) { - textNode = walker.nextNode() - continue - } - - const nextSeen = seen + visibleText.length - - if (offset <= nextSeen) { - return { - node: textNode, - offset: Math.max(0, offset - seen), - } - } - - seen = nextSeen - textNode = walker.nextNode() - } - - return { - node: paragraph, - offset: paragraph.childNodes.length, - } - } - - const anchor = findPoint(anchorOffset) - const focus = findPoint(focusOffset) - const range = document.createRange() - - range.setStart(anchor.node, anchor.offset) - range.setEnd(focus.node, focus.offset) - - const selection = document.getSelection() - - selection?.removeAllRanges() - selection?.addRange(range) - textbox.focus() - - const handle = ( - textbox as HTMLElement & { - __slateBrowserHandle?: { - selectRange: (range: { - anchor: { offset: number; path: number[] } - focus: { offset: number; path: number[] } - }) => void - } - } - ).__slateBrowserHandle - - if (!handle?.selectRange) { - throw new Error(`Peer ${peer} browser handle not ready`) - } - - handle.selectRange(modelRange) - - document.dispatchEvent(new Event('selectionchange')) - }, - { anchorOffset, focusOffset, modelRange, paragraphIndex, peer } - ) - - await expect - .poll(() => - peerTextbox(page, peer).evaluate((textbox) => - ( - textbox as HTMLElement & { - __slateBrowserHandle?: { - getSelection: () => { - anchor: { offset: number; path: number[] } - focus: { offset: number; path: number[] } - } | null - } - } - ).__slateBrowserHandle?.getSelection() - ) - ) - .toEqual(modelRange) -} - -const selectPeerSlateRange = async ( - page: Page, - peer: PeerId, - range: { - anchor: { offset: number; path: number[] } - focus: { offset: number; path: number[] } - } -) => { - await page.evaluate( - ({ peer, range }) => { - const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) - const textbox = root?.querySelector('[role="textbox"]') - - if (!textbox) { - throw new Error(`Peer ${peer} textbox not found`) - } - - const findPoint = (path: number[], offset: number) => { - const [paragraphIndex] = path - const paragraph = textbox.querySelectorAll('p')[paragraphIndex!] - - if (!paragraph) { - throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) - } - - const walker = document.createTreeWalker( - paragraph, - NodeFilter.SHOW_TEXT - ) - let seen = 0 - let textNode = walker.nextNode() - - while (textNode) { - const text = textNode.textContent ?? '' - const visibleText = text.replaceAll('\uFEFF', '') - - if (visibleText.length === 0) { - textNode = walker.nextNode() - continue - } - - const nextSeen = seen + visibleText.length - - if (offset <= nextSeen) { - return { - node: textNode, - offset: Math.max(0, offset - seen), - } - } - - seen = nextSeen - textNode = walker.nextNode() - } - - return { - node: paragraph, - offset: paragraph.childNodes.length, - } - } - - const anchor = findPoint(range.anchor.path, range.anchor.offset) - const focus = findPoint(range.focus.path, range.focus.offset) - const domRange = document.createRange() - - domRange.setStart(anchor.node, anchor.offset) - domRange.setEnd(focus.node, focus.offset) - - const selection = document.getSelection() - - selection?.removeAllRanges() - selection?.addRange(domRange) - textbox.focus() - - const handle = ( - textbox as HTMLElement & { - __slateBrowserHandle?: { - selectRange: (range: { - anchor: { offset: number; path: number[] } - focus: { offset: number; path: number[] } - }) => void - } - } - ).__slateBrowserHandle - - handle?.selectRange?.(range) - document.dispatchEvent(new Event('selectionchange')) - }, - { peer, range } - ) -} - -const selectPeerParagraphNode = async ( - page: Page, - peer: PeerId, - paragraphIndex: number -) => { - await page.evaluate( - ({ paragraphIndex, peer }) => { - const root = document.querySelector(`#yjs-peer-${peer}-editor-surface`) - const textbox = root?.querySelector('[role="textbox"]') - const paragraph = textbox?.querySelectorAll('p')[paragraphIndex] - - if (!textbox || !paragraph) { - throw new Error(`Peer ${peer} paragraph ${paragraphIndex} not found`) - } - - const range = document.createRange() - - range.selectNode(paragraph) - - const selection = document.getSelection() - - selection?.removeAllRanges() - selection?.addRange(range) - textbox.dataset.yjsSelectedParagraphNode = String(paragraphIndex) - textbox.focus() - document.dispatchEvent(new Event('selectionchange')) - }, - { paragraphIndex, peer } - ) -} - -test.describe('yjs collaboration example', () => { - test('renders the full operation control matrix', async ({ page }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - const controls = [ - 'append', - 'replace', - 'remove-node', - 'split-node', - 'merge-node', - 'move-down', - 'set-node', - 'unset-node', - 'wrap-node', - 'unwrap', - 'lift', - 'insert-fragment', - 'delete-fragment', - 'delete-backward', - 'insert-text', - 'move', - ] - - for (const control of controls) { - await expect(byTestId(page, `yjs-peer-a-${control}`)).toBeVisible() - } - }) - - test('keeps set and unset role controls symmetric', async ({ page }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await expect(byTestId(page, 'yjs-peer-a-set-node')).toHaveText('Set Role') - await expect(byTestId(page, 'yjs-peer-a-unset-node')).toHaveText( - 'Unset Role' - ) - - await byTestId(page, 'yjs-peer-a-disconnect').click() - await byTestId(page, 'yjs-peer-a-set-node').click() - - const setCommit = (await editor.get.lastCommit()) as { - operations?: Array<{ - newProperties?: unknown - path?: number[] - properties?: unknown - type?: string - }> - } | null - const setOperation = setCommit?.operations?.find( - (operation) => operation.type === 'set_node' - ) - - expect(setOperation).toEqual( - expect.objectContaining({ - newProperties: { role: 'title' }, - path: [0], - properties: {}, - type: 'set_node', - }) - ) - - await byTestId(page, 'yjs-peer-a-unset-node').click() - - const unsetCommit = (await editor.get.lastCommit()) as { - operations?: Array<{ - newProperties?: unknown - path?: number[] - properties?: unknown - type?: string - }> - } | null - const unsetOperation = unsetCommit?.operations?.find( - (operation) => operation.type === 'set_node' - ) - - expect(unsetOperation).toEqual( - expect.objectContaining({ - newProperties: {}, - path: [0], - properties: { role: 'title' }, - type: 'set_node', - }) - ) - }) - - test('syncs marks applied from one editor to all connected editors', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-select').click() - await byTestId(page, 'yjs-peer-a-mark-bold').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect(peerSurface(page, peer).locator('strong')).toContainText( - 'Hello' - ) - } - }) - - test('projects awareness selection to the remote peer', async ({ page }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-select').click() - - await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( - '101:0.0:0-0.0:5' - ) - }) - - test('clears remote cursor presence while a peer is disconnected', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-select').click() - - await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( - '101:0.0:0-0.0:5' - ) - - await byTestId(page, 'yjs-peer-a-disconnect').click() - - await expect(byTestId(page, 'yjs-peer-b-cursors')).toHaveText('remote:none') - - await byTestId(page, 'yjs-peer-a-connect').click() - - await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( - '101:0.0:0-0.0:5' - ) - }) - - test('keeps peers converged through append, undo, and redo', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-append').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'b')).toContainText('Ada') - - await byTestId(page, 'yjs-peer-a-undo').click() - - await expect(peerSurface(page, 'a')).not.toContainText('Ada') - await expect(peerSurface(page, 'b')).not.toContainText('Ada') - - await byTestId(page, 'yjs-peer-a-redo').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'b')).toContainText('Ada') - }) - - test('shares user history between keyboard undo and redo', async ({ - page, - }) => { - await page.goto( - `${process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3100'}/examples/yjs-collaboration` - ) - await peerSurface(page, 'a').locator('[role="textbox"]').waitFor() - const typedText = ' typed-history' - - await page.evaluate(() => { - const root = document.querySelector('#yjs-peer-a-editor-surface') - const textbox = root?.querySelector('[role="textbox"]') - - if (!textbox?.firstChild) { - throw new Error('Peer A textbox text node not found') - } - - const textNode = document - .createTreeWalker(textbox, NodeFilter.SHOW_TEXT) - .nextNode() - - if (!textNode) { - throw new Error('Peer A textbox text node not found') - } - - const range = document.createRange() - range.setStart(textNode, textNode.textContent?.length ?? 0) - range.setEnd(textNode, textNode.textContent?.length ?? 0) - - const selection = document.getSelection() - selection?.removeAllRanges() - selection?.addRange(range) - textbox.focus() - document.dispatchEvent(new Event('selectionchange')) - }) - await page.keyboard.type(typedText) - - await expect(peerSurface(page, 'a')).toContainText(typedText) - await expect(peerSurface(page, 'b')).toContainText(typedText) - - await peerSurface(page, 'a').locator('[role="textbox"]').focus() - const { redo, undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - await expect(peerSurface(page, 'a')).not.toContainText(typedText) - await expect(peerSurface(page, 'b')).not.toContainText(typedText) - - await page.keyboard.press(redo) - - await expect(peerSurface(page, 'a')).toContainText(typedText) - await expect(peerSurface(page, 'b')).toContainText(typedText) - }) - - test('keyboard undo matches one toolbar undo step and publishes the cursor', async ({ - page, - }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - const initialText = 'Hello world!' - const appendedOnce = `${initialText} Ada` - const appendedTwice = `${appendedOnce} Ada` - const nextSelection = { - anchor: { path: [0, 0], offset: appendedOnce.length }, - focus: { path: [0, 0], offset: appendedOnce.length }, - } - - await editor.selection.selectDOM({ - anchor: { path: [0, 0], offset: initialText.length }, - focus: { path: [0, 0], offset: initialText.length }, - }) - await byTestId(page, 'yjs-peer-a-append').click() - await byTestId(page, 'yjs-peer-a-append').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual([appendedTwice]) - } - - await peerTextbox(page, 'a').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual([appendedOnce]) - } - await expect.poll(() => editor.selection.get()).toEqual(nextSelection) - await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( - `101:0.0:${appendedOnce.length}-0.0:${appendedOnce.length}` - ) - }) - - test('keeps peers usable after selecting all and deleting', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - const editorA = peerTextbox(page, 'a') - - await editorA.click() - await page.keyboard.press('ControlOrMeta+A') - await page.keyboard.press('Backspace') - - await expect(editorA).toBeFocused() - await expect(editorA.locator('p')).toHaveCount(1) - await expect(peerTextbox(page, 'b').locator('p')).toHaveCount(1) - await expect - .poll(() => - editorA.evaluate((textbox) => { - const paragraph = textbox.querySelector('p') - const placeholder = textbox.querySelector( - '[data-slate-placeholder="true"]' - ) - - if (!paragraph || !placeholder) { - return null - } - - const paragraphRect = paragraph.getBoundingClientRect() - const placeholderRect = placeholder.getBoundingClientRect() - - return { - leftDelta: Math.abs(placeholderRect.left - paragraphRect.left), - topDelta: Math.abs(placeholderRect.top - paragraphRect.top), - widthDelta: Math.abs(placeholderRect.width - paragraphRect.width), - } - }) - ) - .toEqual({ leftDelta: 0, topDelta: 0, widthDelta: 0 }) - - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['Hello world!']) - } - - expect(pageErrors).toEqual([]) - }) - - test('keeps single-line select-all deletion focused for continued typing', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - const editorA = peerTextbox(page, 'a') - - await editorA.click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['Hello world!']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Hello world!']) - - await page.keyboard.press('ControlOrMeta+A') - await expect(editorA).toBeFocused() - await expect - .poll(() => page.evaluate(() => getSelection()?.toString())) - .toBe('Hello world!') - - await page.keyboard.press('Backspace') - - await expect(editorA).toBeFocused() - await page.keyboard.type('2') - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect.poll(() => getPeerParagraphTexts(page, peer)).toEqual(['2']) - } - - expect(pageErrors).toEqual([]) - }) - - test('clears stale local undo after a remote replace deletes that edit', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-append').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - - await byTestId(page, 'yjs-peer-b-replace').click() - - await expect(peerSurface(page, 'a')).toContainText( - 'Lin canonical snapshot.' - ) - await expect(peerSurface(page, 'b')).toContainText( - 'Lin canonical snapshot.' - ) - - await peerSurface(page, 'a').locator('[role="textbox"]').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - await expect(peerSurface(page, 'a')).toContainText( - 'Lin canonical snapshot.' - ) - await expect(peerSurface(page, 'b')).toContainText( - 'Lin canonical snapshot.' - ) - expect(pageErrors).toEqual([]) - }) - - test('clears stale local undo after a remote replace deletes an offline mark', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await selectFirstText(page, 'b', 'Hello'.length) - await byTestId(page, 'yjs-peer-b-mark-bold').click() - - await expect(peerSurface(page, 'b').locator('strong')).toContainText( - 'Hello' - ) - await expect(byTestId(page, 'yjs-peer-b-undo')).toBeEnabled() - - await byTestId(page, 'yjs-peer-a-replace').click() - - await expect(peerSurface(page, 'a')).toContainText( - 'Ada canonical snapshot.' - ) - await expect(peerSurface(page, 'b')).toContainText('Hello world!') - - await byTestId(page, 'yjs-peer-b-connect').click() - - await expect(peerSurface(page, 'b')).toContainText( - 'Ada canonical snapshot.' - ) - await expect(byTestId(page, 'yjs-peer-b-undo')).toBeDisabled() - - await peerSurface(page, 'b').locator('[role="textbox"]').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - await expect(peerSurface(page, 'b')).toContainText( - 'Ada canonical snapshot.' - ) - expect(pageErrors).toEqual([]) - }) - - test('exports replace snapshots to connected peers', async ({ page }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-replace').click() - - await expect(peerSurface(page, 'a')).toContainText( - 'Lin canonical snapshot.' - ) - await expect(peerSurface(page, 'b')).toContainText( - 'Lin canonical snapshot.' - ) - await expect(peerSurface(page, 'a')).not.toContainText('Hello world!') - }) - - test('disconnect and connect recover a stale peer', async ({ page }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - - await byTestId(page, 'yjs-peer-a-append').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'b')).not.toContainText('Ada') - - await byTestId(page, 'yjs-peer-b-reconcile').click() - - await expect(peerSurface(page, 'b')).not.toContainText('Ada') - - await byTestId(page, 'yjs-peer-b-connect').click() - await expect(peerSurface(page, 'b')).toContainText('Ada') - }) - - test('merges local disconnected appends when the peer reconnects', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - - await byTestId(page, 'yjs-peer-b-append').click() - - await expect(peerSurface(page, 'b')).toContainText('Lin') - await expect(peerSurface(page, 'a')).not.toContainText('Lin') - - await byTestId(page, 'yjs-peer-a-append').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'b')).not.toContainText('Ada') - - await byTestId(page, 'yjs-peer-b-connect').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'a')).toContainText('Lin') - await expect(peerSurface(page, 'b')).toContainText('Ada') - await expect(peerSurface(page, 'b')).toContainText('Lin') - }) - - test('keeps disconnected local edit undoable after reconnect', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-append').click() - await byTestId(page, 'yjs-peer-a-append').click() - await byTestId(page, 'yjs-peer-b-connect').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'a')).toContainText('Lin') - await expect(peerSurface(page, 'b')).toContainText('Ada') - await expect(peerSurface(page, 'b')).toContainText('Lin') - - await byTestId(page, 'yjs-peer-b-undo').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'a')).not.toContainText('Lin') - await expect(peerSurface(page, 'b')).toContainText('Ada') - await expect(peerSurface(page, 'b')).not.toContainText('Lin') - }) - - test('preserves remote appends when an offline replace is undone before reconnect', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-replace').click() - - await expect(peerSurface(page, 'b')).toContainText( - 'Lin canonical snapshot.' - ) - - await byTestId(page, 'yjs-peer-b-undo').click() - - await expect(peerSurface(page, 'b')).toContainText('Hello world!') - - await byTestId(page, 'yjs-peer-a-append').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'b')).not.toContainText('Ada') - - await byTestId(page, 'yjs-peer-b-connect').click() - - await expect(peerSurface(page, 'a')).toContainText('Ada') - await expect(peerSurface(page, 'b')).toContainText('Ada') - }) - - test('preserves concurrent text when an offline Backspace merge reconnects', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await placePeerCaret(page, 'b', 1, 0) - await page.keyboard.press('Backspace') - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alphabeta']) - - await selectPeerTextRange(page, 'a', 0, 'alpha'.length, 'alpha'.length) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha!', 'beta']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!beta']) - } - }) - - test('undoes offline split paragraph insertion after reconnect', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha']) - await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['alpha']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await placePeerCaret(page, 'b', 0, 'alpha'.length) - await page.keyboard.press('Enter') - await page.keyboard.type('beta') - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await selectPeerTextRange(page, 'a', 0, 'alpha'.length, 'alpha'.length) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!', 'beta']) - } - - await peerTextbox(page, 'b').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!']) - } - }) - - test('preserves remote split when offline split is undone before reconnect', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-disconnect').click() - await byTestId(page, 'yjs-peer-a-split-node').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['Hello ', 'world!']) - - await byTestId(page, 'yjs-peer-a-undo').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['Hello world!']) - - await byTestId(page, 'yjs-peer-b-split-node').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Hello ', 'world!']) - - await byTestId(page, 'yjs-peer-a-connect').click() - - await expectAllPeerParagraphTexts(page, ['Hello ', 'world!']) - expect(pageErrors).toEqual([]) - }) - - test('replays offline split redo onto remote split boundary after reconnect', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-a-disconnect').click() - await byTestId(page, 'yjs-peer-a-split-node').click() - await byTestId(page, 'yjs-peer-a-undo').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['Hello world!']) - - await byTestId(page, 'yjs-peer-b-insert-text').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Hello world!!']) - - await byTestId(page, 'yjs-peer-b-split-node').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Hello ', 'world!!']) - - await byTestId(page, 'yjs-peer-a-connect').click() - await byTestId(page, 'yjs-peer-a-redo').click() - - await expectAllPeerParagraphTexts(page, ['Hello ', 'world!!']) - expect(pageErrors).toEqual([]) - }) - - test('keeps public split button undo converged after reconnect', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alphabeta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alphabeta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-split-node').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alph', 'abeta']) - - await byTestId(page, 'yjs-peer-a-insert-text').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alphabeta!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alph!', 'abeta']) - } - - await byTestId(page, 'yjs-peer-b-undo').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alph!abeta']) - } - }) - - test('undoes a merge followed by a split without a split-history error', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['Hello world!', 'block 2']) - await expectAllPeerParagraphTexts(page, ['Hello world!', 'block 2']) - - await byTestId(page, 'yjs-peer-a-merge-node').click() - await byTestId(page, 'yjs-peer-a-split-node').click() - await byTestId(page, 'yjs-peer-a-undo').click() - - await expectAllPeerParagraphTexts(page, ['Hello world!block 2']) - expect(pageErrors).toEqual([]) - }) - - test('redoes multi-paragraph keyboard input after undoing to an empty document', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['']) - await expectAllPeerParagraphTexts(page, ['']) - - await placePeerCaret(page, 'a', 0, 0) - await page.keyboard.type('a') - await page.keyboard.press('Enter') - await page.keyboard.type('b') - - await expectAllPeerParagraphTexts(page, ['a', 'b']) - - for (let index = 0; index < 2; index++) { - await byTestId(page, 'yjs-peer-a-undo').click() - } - - await expectAllPeerParagraphTexts(page, ['']) - await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() - - for (let index = 0; index < 2; index++) { - await byTestId(page, 'yjs-peer-a-redo').click() - } - - await expectAllPeerParagraphTexts(page, ['a', 'b']) - await expect(byTestId(page, 'yjs-peer-a-redo')).toBeDisabled() - }) - - test('keyboard redoes multi-paragraph input after undoing to an empty document', async ({ - page, - }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['']) - await expectAllPeerParagraphTexts(page, ['']) - - await placePeerCaret(page, 'a', 0, 0) - await page.keyboard.type('a') - await page.keyboard.press('Enter') - await page.keyboard.type('b') - - await expectAllPeerParagraphTexts(page, ['a', 'b']) - - await peerTextbox(page, 'a').focus() - const { redo, undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - await page.keyboard.press(undo) - - await expectAllPeerParagraphTexts(page, ['']) - await expect - .poll(() => editor.selection.get()) - .toEqual({ - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: 0 }, - }) - await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( - '101:0.0:0-0.0:0' - ) - await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() - - await page.keyboard.press(redo) - await page.keyboard.press(redo) - - await expectAllPeerParagraphTexts(page, ['a', 'b']) - await expect - .poll(() => editor.selection.get()) - .toEqual({ - anchor: { path: [1, 0], offset: 1 }, - focus: { path: [1, 0], offset: 1 }, - }) - await expect(byTestId(page, 'yjs-peer-b-cursors')).toContainText( - '101:1.0:1-1.0:1' - ) - await expect(byTestId(page, 'yjs-peer-a-redo')).toBeDisabled() - }) - - test('keeps no-op structural commands out of history', async ({ page }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - const commands = [ - 'remove-node', - 'merge-node', - 'unwrap', - 'lift', - 'unset-node', - ] as const - - for (const command of commands) { - await test.step(command, async () => { - await replacePeerText(page, 'a', ['Hello world!']) - await expectAllPeerParagraphTexts(page, ['Hello world!']) - await expectNoPeerBlockQuotes(page) - await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() - - await byTestId(page, `yjs-peer-a-${command}`).click() - - await expectAllPeerParagraphTexts(page, ['Hello world!']) - await expectNoPeerBlockQuotes(page) - await expect(byTestId(page, 'yjs-peer-a-undo')).toBeDisabled() - }) - } - }) - - test('survives offline structural mix seed 3 without stale leaf paths', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions(page, [ - ['a', 'disconnect'], - ['a', 'unset-node'], - ['d', 'lift'], - ['a', 'move-down'], - ['d', 'wrap-node'], - ['a', 'move-down'], - ['b', 'unwrap'], - ]) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - await expect - .poll(() => getPeerTopLevelBlockTexts(page, 'a')) - .toEqual(['Hello world!', 'block 2']) - for (const peer of ['b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerTopLevelBlockTexts(page, peer)) - .toEqual(['Hello world!']) - } - - await runPeerActions(page, [['a', 'connect']], { errors }) - - await expectAllPeerTopLevelBlockTexts(page, ['Hello world!', 'block 2']) - expect(errors).toEqual([]) - }) - - test('keeps random-control seed 10 prefix from rendering nested paragraphs', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions(page, [ - ['a', 'wrap-node'], - ['c', 'append'], - ['a', 'split-node'], - ['c', 'move-down'], - ['c', 'merge-node'], - ]) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - expect(errors).toEqual([]) - }) - - test('keeps structural edits from rendering placeholder divs inside paragraphs', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['a', 'split-node'], - ['c', 'delete-fragment'], - ['c', 'set-node'], - ['d', 'reconcile'], - ['c', 'redo'], - ['d', 'delete-backward'], - ['a', 'connect'], - ['c', 'remove-node'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps random-control seed 85 from missing Yjs nodes', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'reconcile'], - ['a', 'move-down'], - ['a', 'merge-node'], - ['d', 'replace'], - ['c', 'move'], - ['b', 'connect'], - ['c', 'move-down'], - ['b', 'split-node'], - ['c', 'unset-node'], - ['d', 'append'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps offline structural mix seed 108 from nesting paragraphs', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'disconnect'], - ['b', 'wrap-node'], - ['d', 'wrap-node'], - ['b', 'move-down'], - ['c', 'delete-backward'], - ['b', 'unset-node'], - ['c', 'lift'], - ['b', 'merge-node'], - ['d', 'insert-text'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps structural mix seed 42 from leaf-path crashes', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'disconnect'], - ['b', 'wrap-node'], - ['c', 'split-node'], - ['b', 'move-down'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps offline structural mix seed 16 from losing root text boundaries', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions(page, [ - ['a', 'disconnect'], - ['a', 'merge-node'], - ['d', 'split-node'], - ['a', 'wrap-node'], - ['c', 'split-node'], - ['a', 'delete-fragment'], - ['c', 'move'], - ['a', 'wrap-node'], - ['d', 'split-node'], - ['a', 'connect'], - ]) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - await expectAllPeerTopLevelBlockTexts(page, ['', 'l', 'o ', '', 'world!']) - expect(errors).toEqual([]) - }) - - test('keeps random-control seed 75 from losing root end text boundaries', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions(page, [ - ['b', 'insert-text'], - ['a', 'delete-backward'], - ['b', 'undo'], - ['c', 'insert-fragment'], - ['c', 'disconnect'], - ['d', 'split-node'], - ['d', 'unwrap'], - ['a', 'move'], - ['a', 'delete-backward'], - ['d', 'merge-node'], - ['b', 'redo'], - ['c', 'unset-node'], - ['d', 'delete-fragment'], - ]) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - expect(errors).toEqual([]) - }) - - test('keeps structural warm-up seed 17 from repeating stale leaf paths', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions(page, [ - ['a', 'disconnect'], - ['c', 'split-node'], - ['a', 'merge-node'], - ['b', 'unwrap'], - ['a', 'delete-fragment'], - ['b', 'wrap-node'], - ['a', 'unset-node'], - ['c', 'insert-text'], - ]) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - expect(errors).toEqual([]) - }) - - test('keeps random-control seed 42 from missing Yjs path 1.0', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'insert-text'], - ['c', 'wrap-node'], - ['b', 'append'], - ['b', 'split-node'], - ['d', 'reconcile'], - ['d', 'move'], - ['c', 'remove-node'], - ['c', 'insert-text'], - ['a', 'connect'], - ['d', 'disconnect'], - ['c', 'merge-node'], - ['c', 'unwrap'], - ['d', 'remove-node'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - for (const peer of ['a', 'b', 'c'] as const) { - await expect - .poll(() => getPeerTopLevelBlockTexts(page, peer)) - .toEqual(['Hello wo']) - } - await expect - .poll(() => getPeerTopLevelBlockTexts(page, 'd')) - .toEqual(['Hello world!! Lin!']) - await runPeerActions(page, [['d', 'connect']], { errors }) - await expectAllPeerTopLevelBlockTexts(page, ['Hello wo']) - expect(errors).toEqual([]) - }) - - test('keeps structural mix seed 43 from leaf-path crashes', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'disconnect'], - ['b', 'insert-fragment'], - ['a', 'split-node'], - ['b', 'wrap-node'], - ['d', 'lift'], - ['b', 'wrap-node'], - ['c', 'append'], - ['b', 'move-down'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps structural mix seed 46 from leaf-path crashes', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'disconnect'], - ['b', 'set-node'], - ['a', 'merge-node'], - ['b', 'wrap-node'], - ['a', 'insert-text'], - ['b', 'merge-node'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps structural mix seed 49 from leaf-path crashes', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'disconnect'], - ['b', 'merge-node'], - ['c', 'move'], - ['b', 'wrap-node'], - ['a', 'move'], - ['b', 'unset-node'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps structural mix seed 55 block quote from leaf-path crashes', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'disconnect'], - ['b', 'wrap-node'], - ['a', 'move'], - ['b', 'wrap-node'], - ['a', 'insert-text'], - ['b', 'merge-node'], - ['c', 'merge-node'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerInvalidParagraphDescendants(page) - expect(errors).toEqual([]) - }) - - test('keeps random-control seed 96 from repeating missing Yjs path 1.0', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'reconcile'], - ['c', 'wrap-node'], - ['b', 'delete-fragment'], - ['c', 'set-node'], - ['b', 'split-node'], - ['b', 'move'], - ['b', 'unwrap'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - expect(errors).toEqual([]) - }) - - test('keeps random-control seed 115 from repeating missing Yjs path 1.0', async ({ - page, - }) => { - const errors = watchStructuralBrowserErrors(page) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await runPeerActions( - page, - [ - ['b', 'undo'], - ['c', 'connect'], - ['a', 'unset-node'], - ['d', 'reconcile'], - ['a', 'wrap-node'], - ['d', 'move'], - ['d', 'insert-fragment'], - ['b', 'split-node'], - ['b', 'insert-text'], - ['c', 'unset-node'], - ['d', 'unwrap'], - ], - { errors } - ) - - await expectAllPeerTextboxesAlive(page) - await expectNoPeerNestedParagraphs(page) - expect(errors).toEqual([]) - }) - - test('preserves concurrent text when an offline wrap button reconnects', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-wrap-node').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await byTestId(page, 'yjs-peer-a-insert-text').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha!', 'beta']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!', 'beta']) - } - }) - - test('preserves concurrent text when an offline insert fragment reconnects', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha']) - await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['alpha']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-insert-fragment').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alphaLin fragment']) - - await byTestId(page, 'yjs-peer-a-append').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha Ada']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha AdaLin fragment']) - } - }) - - test('replaces a disconnected wrapped first block without stale text paths', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-c-wrap-node').click() - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-replace').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Lin canonical snapshot.']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - await expectAllPeerParagraphTexts(page, ['Lin canonical snapshot.']) - expect(pageErrors).toEqual([]) - }) - - test('replaces a disconnected fragmented first block without stale text offsets', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-insert-fragment').click() - await expectAllPeerParagraphTexts(page, ['Hello world!Lin fragment']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-replace').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Lin canonical snapshot.']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - await expectAllPeerParagraphTexts(page, ['Lin canonical snapshot.']) - expect(pageErrors).toEqual([]) - }) - - test('splits a wrapped first block without page errors', async ({ page }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-wrap-node').click() - await byTestId(page, 'yjs-peer-d-split-node').click() - - await expectAllPeerParagraphTexts(page, ['Hello ', 'world!']) - expect(pageErrors).toEqual([]) - }) - - test('keeps the initiating peer DOM synchronized after split then merge buttons', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-split-node').click() - await expectAllPeerParagraphTexts(page, ['Hello ', 'world!']) - - await byTestId(page, 'yjs-peer-d-merge-node').click() - - await expectAllPeerParagraphTexts(page, ['Hello world!']) - expect(pageErrors).toEqual([]) - }) - - test('keeps repeated keyboard split and merge from leaking virtual placeholders', async ({ - page, - }) => { - const pageErrors: string[] = [] - const text = 'Hello world!' - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - for (let index = 0; index < 2; index++) { - await selectPeerSlateRange(page, 'a', { - anchor: { path: [0, 0], offset: text.length }, - focus: { path: [0, 0], offset: text.length }, - }) - await page.keyboard.press('Enter') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual([text, '']) - await selectPeerSlateRange(page, 'a', { - anchor: { path: [1, 0], offset: 0 }, - focus: { path: [1, 0], offset: 0 }, - }) - await page.keyboard.press('Backspace') - } - - await expectAllPeerParagraphTexts(page, [text]) - await expectNoPeerNestedParagraphs(page) - expect(pageErrors).toEqual([]) - }) - - test('keeps connected fragment button edits converged without page errors', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-insert-fragment').click() - await expectAllPeerParagraphTexts(page, ['Hello world!Lin fragment']) - - await byTestId(page, 'yjs-peer-d-insert-fragment').click() - - await expectAllPeerParagraphTexts(page, [ - 'Hello world!Lin fragmentEve fragment', - ]) - expect(pageErrors).toEqual([]) - }) - - test('runs text commands inside a wrapped first block without stale paths', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-c-wrap-node').click() - await byTestId(page, 'yjs-peer-b-insert-text').click() - await expectAllPeerParagraphTexts(page, ['Hello world!!']) - - await byTestId(page, 'yjs-peer-d-delete-backward').click() - await expectAllPeerParagraphTexts(page, ['Hello world!']) - - await byTestId(page, 'yjs-peer-a-delete-fragment').click() - await expectAllPeerParagraphTexts(page, [' world!']) - expect(pageErrors).toEqual([]) - }) - - test('uses fresh text paths after append, backspace, and fragment buttons', async ({ - page, - }) => { - const pageErrors: string[] = [] - - page.on('pageerror', (error) => { - pageErrors.push(String(error.message || error)) - }) - - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await byTestId(page, 'yjs-peer-b-append').click() - await expectAllPeerParagraphTexts(page, ['Hello world! Lin']) - - await byTestId(page, 'yjs-peer-d-delete-backward').click() - await expectAllPeerParagraphTexts(page, ['Hello world! Li']) - - await byTestId(page, 'yjs-peer-b-insert-fragment').click() - await expectAllPeerParagraphTexts(page, ['Hello world! LiLin fragment']) - - await byTestId(page, 'yjs-peer-d-delete-backward').click() - - await expectAllPeerParagraphTexts(page, ['Hello world! LiLin fragmen']) - expect(pageErrors).toEqual([]) - }) - - test('undoes offline Backspace merge after a concurrent text edit reconnects', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await placePeerCaret(page, 'b', 1, 0) - await page.keyboard.press('Backspace') - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alphabeta']) - - await selectPeerTextRange(page, 'a', 0, 'alpha'.length, 'alpha'.length) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha!', 'beta']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!beta']) - } - - await peerTextbox(page, 'b').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!', 'beta']) - } - }) - - test('preserves absorbed-block text when an offline expanded deletion reconnects', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta', 'gamma']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta', 'gamma']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await selectPeerSlateRange(page, 'b', { - anchor: { path: [0, 0], offset: 2 }, - focus: { path: [2, 0], offset: 2 }, - }) - await page.keyboard.press('Delete') - await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['almma']) - - await selectPeerTextRange(page, 'a', 2, 'gamma'.length, 'gamma'.length) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha', 'beta', 'gamma!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['almma!']) - } - - await peerTextbox(page, 'b').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha', 'beta', 'gamma!']) - } - }) - - test('preserves concurrent text when an offline block removal reconnects', async ({ - page, - }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await selectPeerParagraphNode(page, 'b', 1) - await page.keyboard.press('Backspace') - await expect.poll(() => getPeerParagraphTexts(page, 'b')).toEqual(['alpha']) - - await editor.selection.selectDOM({ - anchor: { path: [0, 0], offset: 'alpha'.length }, - focus: { path: [0, 0], offset: 'alpha'.length }, - }) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha!', 'beta']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha!']) - } - }) - - test('preserves concurrent text inside a block whose text is removed offline', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await selectPeerTextRange(page, 'b', 1, 0, 'beta'.length) - await page.keyboard.press('Backspace') - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', '']) - - await selectPeerTextRange(page, 'a', 1, 'beta'.length, 'beta'.length) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha', 'beta!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha', '!']) - } - - await peerTextbox(page, 'b').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha', 'beta!']) - } - }) - - test('undoes an offline selection replacement without dropping concurrent text', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha beta']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha beta']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await selectPeerTextRange(page, 'b', 0, 0, 'alpha'.length) - await page.keyboard.type('ALPHA') - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['ALPHA beta']) - - await selectPeerTextRange( - page, - 'a', - 0, - 'alpha beta'.length, - 'alpha beta'.length - ) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha beta!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['ALPHA beta!']) - } - - await peerTextbox(page, 'b').focus() - const { undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha beta!']) - } - }) - - test('preserves concurrent sibling text when an offline move reconnects', async ({ - page, - }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta', 'gamma']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta', 'gamma']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-move').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['beta', 'alpha', 'gamma']) - - await editor.selection.selectDOM({ - anchor: { path: [2, 0], offset: 'gamma'.length }, - focus: { path: [2, 0], offset: 'gamma'.length }, - }) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha', 'beta', 'gamma!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['beta', 'alpha', 'gamma!']) - } - }) - - test('keeps offline move undo and redo converged after reconnect', async ({ - page, - }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await replacePeerText(page, 'a', ['alpha', 'beta', 'gamma']) - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['alpha', 'beta', 'gamma']) - - await byTestId(page, 'yjs-peer-b-disconnect').click() - await byTestId(page, 'yjs-peer-b-move').click() - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['beta', 'alpha', 'gamma']) - - await editor.selection.selectDOM({ - anchor: { path: [2, 0], offset: 'gamma'.length }, - focus: { path: [2, 0], offset: 'gamma'.length }, - }) - await page.keyboard.type('!') - await expect - .poll(() => getPeerParagraphTexts(page, 'a')) - .toEqual(['alpha', 'beta', 'gamma!']) - - await byTestId(page, 'yjs-peer-b-connect').click() - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['beta', 'alpha', 'gamma!']) - } - - await peerTextbox(page, 'b').focus() - const { redo, undo } = await getHistoryShortcuts(page) - - await page.keyboard.press(undo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['alpha', 'beta', 'gamma!']) - } - - await page.keyboard.press(redo) - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['beta', 'alpha', 'gamma!']) - } - }) - - test('keeps peer DOM layout synchronized after rapid history button replay', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await placePeerCaret(page, 'a', 0, 'Hello world!'.length) - - for (let index = 0; index < 3; index++) { - await page.keyboard.press('Enter') - } - - await expect - .poll(() => getPeerParagraphTexts(page, 'b')) - .toEqual(['Hello world!', '', '', '']) - - for (let index = 0; index < 3; index++) { - await byTestId(page, 'yjs-peer-a-undo').click() - } - for (let index = 0; index < 3; index++) { - await byTestId(page, 'yjs-peer-a-redo').click() - } - - await expect - .poll(async () => { - const proofs = await Promise.all( - (['a', 'b', 'c', 'd'] as const).map((peer) => - getPeerLayoutProof(page, peer) - ) - ) - const heights = proofs.map((proof) => proof.editorHeight) - const heightSpread = Math.max(...heights) - Math.min(...heights) - const paragraphHeights = proofs.flatMap((proof) => - proof.paragraphs.map((paragraph) => paragraph.height) - ) - const paragraphHeightSpread = - Math.max(...paragraphHeights) - Math.min(...paragraphHeights) - const paragraphTexts = proofs.map((proof) => - proof.paragraphs.map((paragraph) => paragraph.text) - ) - - return { - heightSpread, - paragraphHeightSpread, - paragraphTexts, - } - }) - .toEqual({ - heightSpread: 0, - paragraphHeightSpread: 0, - paragraphTexts: [ - ['Hello world!', '', '', ''], - ['Hello world!', '', '', ''], - ['Hello world!', '', '', ''], - ['Hello world!', '', '', ''], - ], - }) - }) - - test('merges offline mark, text replacement, and paragraph insertion edits', async ({ - page, - }) => { - await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - for (const peer of ['b', 'c', 'd'] as const) { - await byTestId(page, `yjs-peer-${peer}-disconnect`).click() - } - - await selectFirstText(page, 'b', 'Hello'.length) - await byTestId(page, 'yjs-peer-b-mark-bold').click() - await expect(peerSurface(page, 'b').locator('strong')).toContainText( - 'Hello' - ) - - await selectFirstText(page, 'c', 'Hello'.length) - await page.keyboard.type('Hi') - - await placePeerCaret(page, 'd', 0, 'Hello world!'.length) - await page.keyboard.press('Enter') - await page.keyboard.type('Test') - - await expect(peerSurface(page, 'c')).toContainText('Hi world!') - await expect(peerSurface(page, 'd')).toContainText('Test') - - for (const peer of ['b', 'c', 'd'] as const) { - await byTestId(page, `yjs-peer-${peer}-connect`).click() - } - - for (const peer of ['a', 'b', 'c', 'd'] as const) { - await expect(peerSurface(page, peer)).toContainText('Hi world!') - await expect(peerSurface(page, peer)).toContainText('Test') - await expect - .poll(() => getPeerParagraphTexts(page, peer)) - .toEqual(['Hi world!', 'Test']) - } - }) - - test('keeps expanded browser selection deletion synchronized', async ({ - page, - }) => { - const editor = await openExample(page, 'yjs-collaboration', { - ready: { editor: 'visible' }, - surface: { scope: '#yjs-peer-a-editor-surface' }, - }) - - await editor.selection.selectDOM({ - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: 'Hello'.length }, - }) - await editor.assert.selection({ - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: 'Hello'.length }, - }) - - await page.keyboard.press('Delete') - - await expect(peerSurface(page, 'a')).not.toContainText('Hello') - await expect(peerSurface(page, 'b')).not.toContainText('Hello') - await expect - .poll(() => editor.selection.get()) - .toEqual({ - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: 0 }, - }) - }) -}) diff --git a/scripts/proof/yjs-collaboration-soak.mjs b/scripts/proof/yjs-collaboration-soak.mjs index 929353675b..624c65ac2f 100644 --- a/scripts/proof/yjs-collaboration-soak.mjs +++ b/scripts/proof/yjs-collaboration-soak.mjs @@ -16,12 +16,15 @@ const baseUrl = const TARGET_URL = process.env.SOAK_URL ?? `${baseUrl.replace(/\/$/, '')}/examples/yjs-collaboration` +const IS_HOCUSPOCUS_TARGET = TARGET_URL.includes('/examples/yjs-hocuspocus') const CDP = process.env.SOAK_CDP ?? 'http://127.0.0.1:9222' const DURATION_MS = Number(process.env.SOAK_MS ?? 3 * 60 * 60 * 1000) const ACTION_DELAY_MS = Number(process.env.SOAK_ACTION_DELAY_MS ?? 1000) const REPORT_EVERY_MS = Number(process.env.SOAK_REPORT_EVERY_MS ?? 60 * 1000) const RUN_ID = process.env.SOAK_RUN_ID ?? new Date().toISOString().replace(/[:.]/g, '-') +const TRACE_SCENARIO = process.env.SOAK_TRACE_SCENARIO +const TRACE_SNAPSHOTS = process.env.SOAK_TRACE_SNAPSHOTS === '1' const OUTPUT_ROOT = process.env.SOAK_OUTPUT_ROOT ?? 'test-results/yjs-collaboration-soak' const OUT_DIR = path.resolve(repoRoot, OUTPUT_ROOT, RUN_ID) @@ -205,17 +208,38 @@ async function getExistingPage(browser) { const pages = context.pages() return ( pages.find((candidate) => - candidate.url().includes('/examples/yjs-collaboration') + ['/examples/yjs-collaboration', '/examples/yjs-hocuspocus'].some( + (pathname) => candidate.url().includes(pathname) + ) ) ?? pages.find((candidate) => !candidate.isClosed()) ?? (await context.newPage()) ) } +function scenarioUrl(reason) { + if (!IS_HOCUSPOCUS_TARGET) { + return TARGET_URL + } + + const url = new URL(TARGET_URL) + const slug = String(reason) + .replace(/[^a-z0-9_.:-]/gi, '-') + .slice(0, 80) + + url.searchParams.set( + 'room', + `codex-hocuspocus-soak-${RUN_ID}-${metrics.hardResets}-${slug}` + ) + + return url.toString() +} + async function navigate(reason) { metrics.hardResets += 1 - write({ type: 'navigate', reason, url: TARGET_URL }) - await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }) + const url = scenarioUrl(reason) + write({ type: 'navigate', reason, url }) + await page.goto(url, { waitUntil: 'domcontentloaded' }) await page.locator('[data-test-id="yjs-peer-a-append"]').waitFor({ timeout: 30_000, }) @@ -248,6 +272,7 @@ async function click(peer, action, scenario) { metrics.actions += 1 write({ type: 'action', peer, action, scenario }) await sleep(ACTION_DELAY_MS) + await traceSnapshot(scenario, `${peer}:${action}`) return true } @@ -260,6 +285,7 @@ async function connectAll(scenario) { async function snapshot() { return await page.evaluate(() => { + const normalizeSlateText = (text) => text?.replaceAll('\uFEFF', '') ?? '' const roots = Array.from( document.querySelectorAll('[contenteditable="true"]') ) @@ -271,13 +297,13 @@ async function snapshot() { .length, path: el.getAttribute('data-slate-path'), tag: el.tagName, - text: el.textContent, + text: normalizeSlateText(el.textContent), })) return { blocks, index, - text: root.textContent, + text: normalizeSlateText(root.textContent), } }) @@ -307,6 +333,24 @@ function sameJson(left, right) { return JSON.stringify(left) === JSON.stringify(right) } +async function traceSnapshot(scenario, label) { + if (!TRACE_SNAPSHOTS) { + return + } + if (TRACE_SCENARIO && TRACE_SCENARIO !== scenario) { + return + } + + const snap = await snapshot() + + write({ + type: 'snapshot', + label, + scenario, + texts: blockTexts(snap), + }) +} + async function checkShape(scenario, label) { const snap = await snapshot() if (snap.editorCount !== 4) { diff --git a/scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs b/scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs new file mode 100644 index 0000000000..5a461cf197 --- /dev/null +++ b/scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs @@ -0,0 +1,667 @@ +#!/usr/bin/env bun + +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium } from '@playwright/test' + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) +const port = process.env.PORT ?? '3100' +const baseUrl = + process.env.PERSISTENT_SOAK_BASE_URL ?? + process.env.PLAYWRIGHT_BASE_URL ?? + `http://localhost:${port}` +const targetUrl = + process.env.PERSISTENT_SOAK_URL ?? + `${baseUrl.replace(/\/$/, '')}/examples/yjs-hocuspocus` +const yjsUrl = process.env.PERSISTENT_SOAK_YJS_URL ?? 'ws://localhost:4444/yjs' +const yjsPort = Number(new URL(yjsUrl).port || 4444) +const runId = + process.env.PERSISTENT_SOAK_RUN_ID ?? + `persistent-room-${new Date().toISOString().replace(/[:.]/g, '-')}` +const outputRoot = + process.env.PERSISTENT_SOAK_OUTPUT_ROOT ?? + 'test-results/yjs-hocuspocus-persistent-room-soak' +const outDir = path.resolve(repoRoot, outputRoot, runId) +const storageDir = + process.env.PERSISTENT_SOAK_STORAGE_DIR ?? path.join(outDir, 'documents') +const logPath = path.join(outDir, 'events.jsonl') +const summaryPath = path.join(outDir, 'summary.md') +const durationMs = Number(process.env.PERSISTENT_SOAK_MS ?? 60 * 60 * 1000) +const actionDelayMs = Number( + process.env.PERSISTENT_SOAK_ACTION_DELAY_MS ?? 1000 +) +const reportEveryMs = Number( + process.env.PERSISTENT_SOAK_REPORT_EVERY_MS ?? 60_000 +) +const convergenceTimeoutMs = Number( + process.env.PERSISTENT_SOAK_CONVERGENCE_TIMEOUT_MS ?? 12_000 +) +const authToken = process.env.PERSISTENT_SOAK_AUTH_TOKEN ?? 'persistent-soak' +const roomName = process.env.PERSISTENT_SOAK_ROOM ?? `persistent-room-${runId}` +const shouldStartServers = process.env.PERSISTENT_SOAK_START_SERVERS !== '0' +const shouldFailOnIssues = process.env.PERSISTENT_SOAK_FAIL_ON_ISSUES === '1' +const shouldHeadless = process.env.SOAK_HEADLESS === '1' +const IGNORE_CONSOLE_RE = + /Download the React DevTools|favicon\.ico|\[HMR\] Invalid message/ +const ERROR_RE = /Cannot|No Yjs|hydration|nested|uncaught|error/i + +const peers = ['a', 'b', 'c', 'd'] + +fs.mkdirSync(outDir, { recursive: true }) + +const startedAt = Date.now() +const issues = new Map() +const metrics = { + actions: 0, + checkpoints: 0, + consoleErrors: 0, + offlineWindows: 0, + pageErrors: 0, + scenarios: Object.create(null), + skippedDisabled: 0, +} +const growthSamples = [] + +let browser +let siteServer +let yjsServer +let lastAction = null +let lastReportAt = Date.now() + +function write(event) { + fs.appendFileSync( + logPath, + `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n` + ) +} + +function issueKey(kind, scenario, detail) { + return `${kind}|${scenario}|${JSON.stringify(detail).slice(0, 800)}` +} + +function recordIssue(kind, scenario, detail, severity = 'error') { + const key = issueKey(kind, scenario, detail) + const existing = issues.get(key) + + if (existing) { + existing.count += 1 + existing.lastAt = new Date().toISOString() + existing.lastAction = lastAction + write({ type: 'issue-repeat', count: existing.count, key }) + return existing + } + + const issue = { + count: 1, + detail, + firstAt: new Date().toISOString(), + kind, + lastAction, + lastAt: new Date().toISOString(), + scenario, + severity, + } + + issues.set(key, issue) + write({ type: 'issue', issue, key }) + console.log( + `[issue:${severity}] ${kind} ${scenario} ${JSON.stringify(detail).slice(0, 240)}` + ) + return issue +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function pacedSleep(multiplier = 1) { + await sleep(actionDelayMs * multiplier) +} + +async function waitForUrl(url, timeoutMs = 60_000) { + const started = Date.now() + let lastError = null + + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url) + + if (response.ok) { + return + } + + lastError = new Error(`${url} returned ${response.status}`) + } catch (error) { + lastError = error + } + + await sleep(250) + } + + throw lastError ?? new Error(`${url} did not become ready`) +} + +async function waitForPort(checkPort, host = '127.0.0.1', timeoutMs = 60_000) { + const started = Date.now() + let lastError = null + + while (Date.now() - started < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.connect(checkPort, host) + socket.once('connect', () => { + socket.end() + resolve() + }) + socket.once('error', reject) + socket.setTimeout(1000, () => { + socket.destroy() + reject(new Error(`${host}:${checkPort} timed out`)) + }) + }) + return + } catch (error) { + lastError = error + await sleep(250) + } + } + + throw lastError ?? new Error(`${host}:${checkPort} did not become ready`) +} + +async function startServers() { + if (!shouldStartServers) { + return + } + + try { + await waitForPort(yjsPort, '127.0.0.1', 1000) + } catch { + yjsServer = spawn('bun', ['start:yjs'], { + cwd: repoRoot, + env: { + ...process.env, + SLATE_YJS_AUTH_TOKEN: authToken, + SLATE_YJS_STORAGE_DIR: storageDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + yjsServer.stdout.on('data', (chunk) => process.stdout.write(chunk)) + yjsServer.stderr.on('data', (chunk) => process.stderr.write(chunk)) + await waitForPort(yjsPort) + } + + try { + await waitForUrl(targetUrl, 1000) + } catch { + siteServer = spawn('bun', ['serve'], { + cwd: repoRoot, + env: { + ...process.env, + NEXT_PUBLIC_SLATE_YJS_TOKEN: authToken, + NEXT_PUBLIC_SLATE_YJS_URL: yjsUrl, + PORT: port, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + siteServer.stdout.on('data', (chunk) => process.stdout.write(chunk)) + siteServer.stderr.on('data', (chunk) => process.stderr.write(chunk)) + await waitForUrl(targetUrl) + } +} + +function peerUrl(peerId) { + const url = new URL(targetUrl) + url.searchParams.set('room', roomName) + url.searchParams.set('peer', peerId) + return url.toString() +} + +async function createPeer(peerId) { + const context = await browser.newContext({ + viewport: { height: 900, width: 1100 }, + }) + const page = await context.newPage() + const peer = { context, id: peerId, page } + + page.on('console', (msg) => { + const text = msg.text() + + if (IGNORE_CONSOLE_RE.test(text)) { + write({ + type: 'console-ignored', + messageType: msg.type(), + peer: peerId, + text, + }) + return + } + + if (msg.type() === 'error' || ERROR_RE.test(text)) { + metrics.consoleErrors += 1 + recordIssue( + 'console', + `peer-${peerId}`, + { messageType: msg.type(), text }, + 'error' + ) + } + }) + page.on('pageerror', (error) => { + metrics.pageErrors += 1 + recordIssue('pageerror', `peer-${peerId}`, { message: error.message }) + }) + + await page.goto(peerUrl(peerId), { waitUntil: 'domcontentloaded' }) + await page.locator(`[data-test-id="yjs-peer-${peerId}-append"]`).waitFor({ + timeout: 30_000, + }) + await page.waitForFunction( + () => document.querySelectorAll('[contenteditable="true"]').length === 1, + null, + { timeout: 30_000 } + ) + await pacedSleep(2) + + return peer +} + +async function click(peer, action, scenario) { + lastAction = { action, peer: peer.id, scenario } + const locator = peer.page + .locator(`[data-test-id="yjs-peer-${peer.id}-${action}"]`) + .first() + + if ((await locator.count()) === 0) { + recordIssue('missing-control', scenario, { action, peer: peer.id }) + return false + } + if (await locator.isDisabled()) { + metrics.skippedDisabled += 1 + write({ type: 'skip-disabled', action, peer: peer.id, scenario }) + await pacedSleep() + return false + } + + await locator.scrollIntoViewIfNeeded() + try { + await locator.click({ timeout: 10_000 }) + } catch (error) { + if (!String(error?.message ?? '').includes('')) { + throw error + } + + write({ + type: 'dev-overlay-click-fallback', + action, + peer: peer.id, + scenario, + }) + await locator.dispatchEvent('pointerdown', { + button: 0, + buttons: 1, + pointerId: 1, + pointerType: 'mouse', + }) + } + + metrics.actions += 1 + write({ type: 'action', action, peer: peer.id, scenario }) + await pacedSleep() + return true +} + +async function snapshotPeer(peer) { + return await peer.page.evaluate(() => { + const normalize = (text) => text?.replaceAll('\uFEFF', '') ?? '' + const root = document.querySelector('[contenteditable="true"]') + const blocks = root + ? Array.from( + root.querySelectorAll(':scope > [data-slate-node="element"]') + ).map((el) => ({ + childElementCount: el.querySelectorAll('[data-slate-node="element"]') + .length, + path: el.getAttribute('data-slate-path'), + text: normalize(el.textContent), + })) + : [] + const text = blocks.map((block) => block.text).join('\n') + + return { + blockCount: blocks.length, + blocks, + bodyText: normalize(document.body.textContent).slice(0, 1000), + charCount: text.length, + editorCount: document.querySelectorAll('[contenteditable="true"]').length, + nestedDivInP: document.querySelectorAll('[contenteditable="true"] p div') + .length, + nestedParagraphCount: document.querySelectorAll( + '[contenteditable="true"] p p' + ).length, + text, + url: location.href, + } + }) +} + +async function snapshot(peersById) { + const entries = await Promise.all( + peers.map(async (peerId) => [peerId, await snapshotPeer(peersById[peerId])]) + ) + + return Object.fromEntries(entries) +} + +function blockTexts(snap) { + return peers.map((peerId) => snap[peerId].blocks.map((block) => block.text)) +} + +function sameJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right) +} + +function summarizeGrowth(snap, scenario, label) { + const peerSnaps = peers.map((peerId) => snap[peerId]) + const blockCounts = peerSnaps.map((peerSnap) => peerSnap.blockCount) + const charCounts = peerSnaps.map((peerSnap) => peerSnap.charCount) + const sample = { + blockCounts, + charCounts, + elapsedMs: Date.now() - startedAt, + label, + maxBlocks: Math.max(...blockCounts), + maxChars: Math.max(...charCounts), + minBlocks: Math.min(...blockCounts), + minChars: Math.min(...charCounts), + scenario, + } + + growthSamples.push(sample) + if (growthSamples.length > 25) { + growthSamples.shift() + } + write({ type: 'growth-sample', sample }) + return sample +} + +async function checkShape(peersById, scenario, label) { + const snap = await snapshot(peersById) + + for (const peerId of peers) { + const peerSnap = snap[peerId] + + if (peerSnap.editorCount !== 1) { + recordIssue('editor-count', scenario, { + editorCount: peerSnap.editorCount, + label, + peer: peerId, + }) + } + if (peerSnap.nestedParagraphCount > 0) { + recordIssue('nested-paragraph', scenario, { + label, + nestedParagraphCount: peerSnap.nestedParagraphCount, + peer: peerId, + texts: blockTexts(snap), + }) + } + if (peerSnap.nestedDivInP > 0) { + recordIssue('nested-div-in-paragraph', scenario, { + label, + nestedDivInP: peerSnap.nestedDivInP, + peer: peerId, + texts: blockTexts(snap), + }) + } + } + + summarizeGrowth(snap, scenario, label) + return snap +} + +async function waitForConvergence(peersById, scenario, label) { + const started = Date.now() + let lastSnap = null + + while (Date.now() - started < convergenceTimeoutMs) { + lastSnap = await checkShape(peersById, scenario, label) + const texts = blockTexts(lastSnap) + const first = texts[0] + + if (texts.every((candidate) => sameJson(candidate, first))) { + metrics.checkpoints += 1 + return lastSnap + } + + await sleep(500) + } + + const texts = blockTexts(lastSnap ?? (await snapshot(peersById))) + recordIssue('non-convergence', scenario, { label, texts }) + return lastSnap +} + +async function runScenario(name, fn) { + metrics.scenarios[name] = (metrics.scenarios[name] ?? 0) + 1 + write({ type: 'scenario-start', name }) + + try { + await fn(name) + } catch (error) { + recordIssue('scenario-exception', name, { + message: error?.message ?? String(error), + stack: String(error?.stack ?? '').slice(0, 3000), + }) + } finally { + write({ type: 'scenario-end', name }) + } +} + +async function scenarioGrowthBurst(peersById, name) { + for (const peerId of peers) { + await click(peersById[peerId], 'append', name) + await click(peersById[peerId], 'insert-text', name) + } + await waitForConvergence(peersById, name, 'after text growth burst') +} + +async function scenarioBlockGrowth(peersById, name) { + await click(peersById.a, 'split-node', name) + await click(peersById.b, 'append', name) + await click(peersById.c, 'split-node', name) + await click(peersById.d, 'insert-fragment', name) + await waitForConvergence(peersById, name, 'after block growth') +} + +async function scenarioStructureChurn(peersById, name) { + await click(peersById.a, 'wrap-node', name) + await click(peersById.b, 'set-node', name) + await click(peersById.c, 'move-down', name) + await click(peersById.d, 'insert-fragment', name) + await click(peersById.a, 'unwrap', name) + await click(peersById.b, 'unset-node', name) + await waitForConvergence(peersById, name, 'after structure churn') +} + +async function scenarioOfflineCatchup(peersById, name) { + metrics.offlineWindows += 1 + await click(peersById.c, 'disconnect', name) + await click(peersById.c, 'append', name) + await click(peersById.c, 'insert-text', name) + await click(peersById.a, 'append', name) + await click(peersById.b, 'split-node', name) + await click(peersById.d, 'insert-fragment', name) + await click(peersById.c, 'connect', name) + await click(peersById.a, 'reconcile', name) + await waitForConvergence(peersById, name, 'after offline catchup') +} + +async function scenarioHistoryInterleave(peersById, name) { + await click(peersById.a, 'append', name) + await click(peersById.a, 'undo', name) + await click(peersById.b, 'append', name) + await click(peersById.c, 'split-node', name) + await click(peersById.a, 'redo', name) + await click(peersById.d, 'insert-text', name) + await waitForConvergence(peersById, name, 'after history interleave') +} + +function writeSummary(final = false) { + const sortedIssues = [...issues.values()].sort((a, b) => { + const order = { error: 0, suspect: 1, warning: 2 } + return (order[a.severity] ?? 9) - (order[b.severity] ?? 9) + }) + const latestGrowth = growthSamples.at(-1) + const lines = [ + '# Yjs Hocuspocus Persistent Room Soak', + '', + `- status: ${final ? 'complete' : 'running'}`, + `- url: ${targetUrl}`, + `- yjs_url: ${yjsUrl}`, + `- room: ${roomName}`, + `- run_id: ${runId}`, + `- elapsed_ms: ${Date.now() - startedAt}`, + `- actions: ${metrics.actions}`, + `- checkpoints: ${metrics.checkpoints}`, + `- offline_windows: ${metrics.offlineWindows}`, + `- skipped_disabled: ${metrics.skippedDisabled}`, + `- console_errors: ${metrics.consoleErrors}`, + `- page_errors: ${metrics.pageErrors}`, + `- issues: ${sortedIssues.length}`, + `- storage_dir: ${storageDir}`, + `- log: ${logPath}`, + '', + '## Latest Growth', + '', + latestGrowth + ? `- ${JSON.stringify(latestGrowth)}` + : '- No growth sample recorded yet.', + '', + '## Recent Growth Samples', + '', + ...growthSamples.slice(-10).map((sample) => `- ${JSON.stringify(sample)}`), + '', + '## Scenario Counts', + '', + ...Object.entries(metrics.scenarios).map( + ([name, count]) => `- ${name}: ${count}` + ), + '', + '## Issues', + '', + ...(sortedIssues.length === 0 + ? ['None recorded yet.'] + : sortedIssues.map((issue, index) => + [ + `### ${index + 1}. ${issue.kind}`, + '', + `- severity: ${issue.severity}`, + `- scenario: ${issue.scenario}`, + `- count: ${issue.count}`, + `- first_at: ${issue.firstAt}`, + `- last_at: ${issue.lastAt}`, + `- last_action: ${JSON.stringify(issue.lastAction)}`, + `- detail: ${JSON.stringify(issue.detail)}`, + '', + ].join('\n') + )), + '', + ] + + fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`) +} + +async function main() { + write({ + type: 'start', + config: { + actionDelayMs, + authTokenConfigured: Boolean(authToken), + convergenceTimeoutMs, + durationMs, + roomName, + shouldHeadless, + shouldStartServers, + storageDir, + summaryPath, + targetUrl, + yjsUrl, + }, + }) + + await startServers() + await waitForUrl(targetUrl) + browser = await chromium.launch({ headless: shouldHeadless }) + + const peerEntries = await Promise.all( + peers.map(async (peerId) => [peerId, await createPeer(peerId)]) + ) + const peersById = Object.fromEntries(peerEntries) + + await waitForConvergence(peersById, 'initial', 'initial convergence') + + while (Date.now() - startedAt < durationMs) { + await runScenario('growth-burst', (name) => + scenarioGrowthBurst(peersById, name) + ) + await runScenario('block-growth', (name) => + scenarioBlockGrowth(peersById, name) + ) + await runScenario('structure-churn', (name) => + scenarioStructureChurn(peersById, name) + ) + await runScenario('offline-catchup', (name) => + scenarioOfflineCatchup(peersById, name) + ) + await runScenario('history-interleave', (name) => + scenarioHistoryInterleave(peersById, name) + ) + + if (Date.now() - lastReportAt >= reportEveryMs) { + writeSummary(false) + lastReportAt = Date.now() + console.log( + `[progress] elapsed=${Math.round((Date.now() - startedAt) / 1000)}s actions=${metrics.actions} checkpoints=${metrics.checkpoints} issues=${issues.size} summary=${summaryPath}` + ) + } + } + + writeSummary(true) + write({ type: 'complete', issues: [...issues.values()], metrics }) + console.log(`[complete] summary=${summaryPath}`) + console.log(`[complete] log=${logPath}`) + + if (shouldFailOnIssues && issues.size > 0) { + process.exitCode = 1 + } +} + +async function cleanup() { + if (browser) { + await browser.close() + } + if (siteServer) { + siteServer.kill() + } + if (yjsServer) { + yjsServer.kill() + } +} + +main() + .catch((error) => { + recordIssue('runner-fatal', 'main', { + message: error?.message ?? String(error), + stack: String(error?.stack ?? '').slice(0, 4000), + }) + writeSummary(true) + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await cleanup() + }) diff --git a/scripts/proof/yjs-hocuspocus-production-soak.mjs b/scripts/proof/yjs-hocuspocus-production-soak.mjs new file mode 100644 index 0000000000..1244ff7151 --- /dev/null +++ b/scripts/proof/yjs-hocuspocus-production-soak.mjs @@ -0,0 +1,722 @@ +#!/usr/bin/env bun + +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium } from '@playwright/test' + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) +const PORT = process.env.PORT ?? '3100' +const baseUrl = + process.env.PRODUCTION_SOAK_BASE_URL ?? + process.env.PLAYWRIGHT_BASE_URL ?? + `http://localhost:${PORT}` +const targetUrl = + process.env.PRODUCTION_SOAK_URL ?? + `${baseUrl.replace(/\/$/, '')}/examples/yjs-hocuspocus` +const yjsUrl = process.env.PRODUCTION_SOAK_YJS_URL ?? 'ws://localhost:4444/yjs' +const yjsPort = Number(new URL(yjsUrl).port || 4444) +const runId = + process.env.PRODUCTION_SOAK_RUN_ID ?? + `production-hocuspocus-${new Date().toISOString().replace(/[:.]/g, '-')}` +const outputRoot = + process.env.PRODUCTION_SOAK_OUTPUT_ROOT ?? + 'test-results/yjs-hocuspocus-production-soak' +const outDir = path.resolve(repoRoot, outputRoot, runId) +const storageDir = + process.env.PRODUCTION_SOAK_STORAGE_DIR ?? path.join(outDir, 'documents') +const logPath = path.join(outDir, 'events.jsonl') +const summaryPath = path.join(outDir, 'summary.md') +const durationMs = Number(process.env.PRODUCTION_SOAK_MS ?? 60_000) +const actionDelayMs = Number(process.env.PRODUCTION_SOAK_ACTION_DELAY_MS ?? 250) +const jitterMs = Number(process.env.PRODUCTION_SOAK_JITTER_MS ?? 200) +const authToken = process.env.PRODUCTION_SOAK_AUTH_TOKEN ?? 'production-soak' +const shouldStartServers = process.env.PRODUCTION_SOAK_START_SERVERS !== '0' +const shouldFailOnIssues = process.env.PRODUCTION_SOAK_FAIL_ON_ISSUES === '1' + +const peers = ['a', 'b', 'c', 'd'] +const randomActions = [ + 'append', + 'insert-text', + 'split-node', + 'merge-node', + 'wrap-node', + 'unwrap', + 'lift', + 'insert-fragment', + 'delete-fragment', + 'delete-backward', + 'move', + 'set-node', + 'unset-node', + 'mark-bold', + 'undo', + 'redo', + 'move-down', + 'remove-node', + 'reconcile', +] +const networkProfiles = { + baseline: { + downloadThroughput: -1, + latency: 0, + offline: false, + uploadThroughput: -1, + }, + production: { + downloadThroughput: 1_200_000, + latency: 90, + offline: false, + uploadThroughput: 450_000, + }, + degraded: { + downloadThroughput: 240_000, + latency: 320, + offline: false, + uploadThroughput: 90_000, + }, + offline: { + downloadThroughput: 0, + latency: 0, + offline: true, + uploadThroughput: 0, + }, +} + +fs.mkdirSync(outDir, { recursive: true }) + +const startedAt = Date.now() +let scenarioStartedAt = startedAt +const issues = new Map() +const metrics = { + actions: 0, + browserOfflineWindows: 0, + consoleErrors: 0, + hardReloads: 0, + pageErrors: 0, + scenarios: Object.create(null), +} + +let browser +let siteServer +let yjsServer +let lastAction = null + +function write(event) { + fs.appendFileSync( + logPath, + `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n` + ) +} + +function issueKey(kind, scenario, detail) { + return `${kind}|${scenario}|${JSON.stringify(detail).slice(0, 500)}` +} + +function recordIssue(kind, scenario, detail, severity = 'error') { + const key = issueKey(kind, scenario, detail) + const existing = issues.get(key) + + if (existing) { + existing.count += 1 + existing.lastAt = new Date().toISOString() + write({ type: 'issue-repeat', count: existing.count, key }) + return existing + } + + const issue = { + count: 1, + detail, + firstAt: new Date().toISOString(), + kind, + lastAction, + lastAt: new Date().toISOString(), + scenario, + severity, + } + + issues.set(key, issue) + write({ type: 'issue', issue, key }) + console.log( + `[issue:${severity}] ${kind} ${scenario} ${JSON.stringify(detail).slice(0, 220)}` + ) + return issue +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function pacedSleep(multiplier = 1) { + const jitter = jitterMs > 0 ? Math.floor(Math.random() * jitterMs) : 0 + await sleep(actionDelayMs * multiplier + jitter) +} + +async function waitForUrl(url, timeoutMs = 60_000) { + const started = Date.now() + let lastError = null + + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url) + + if (response.ok) { + return + } + + lastError = new Error(`${url} returned ${response.status}`) + } catch (error) { + lastError = error + } + + await sleep(250) + } + + throw lastError ?? new Error(`${url} did not become ready`) +} + +async function waitForPort(port, host = '127.0.0.1', timeoutMs = 60_000) { + const started = Date.now() + let lastError = null + + while (Date.now() - started < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.connect(port, host) + socket.once('connect', () => { + socket.end() + resolve() + }) + socket.once('error', reject) + socket.setTimeout(1000, () => { + socket.destroy() + reject(new Error(`${host}:${port} timed out`)) + }) + }) + return + } catch (error) { + lastError = error + await sleep(250) + } + } + + throw lastError ?? new Error(`${host}:${port} did not become ready`) +} + +async function startServers() { + if (!shouldStartServers) { + return + } + + try { + await waitForPort(yjsPort, '127.0.0.1', 1000) + } catch { + yjsServer = spawn('bun', ['start:yjs'], { + cwd: repoRoot, + env: { + ...process.env, + SLATE_YJS_AUTH_TOKEN: authToken, + SLATE_YJS_STORAGE_DIR: storageDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + yjsServer.stdout.on('data', (chunk) => process.stdout.write(chunk)) + yjsServer.stderr.on('data', (chunk) => process.stderr.write(chunk)) + await waitForPort(yjsPort) + } + + try { + await waitForUrl(targetUrl, 1000) + } catch { + siteServer = spawn('bun', ['serve'], { + cwd: repoRoot, + env: { + ...process.env, + NEXT_PUBLIC_SLATE_YJS_TOKEN: authToken, + NEXT_PUBLIC_SLATE_YJS_URL: yjsUrl, + PORT, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + siteServer.stdout.on('data', (chunk) => process.stdout.write(chunk)) + siteServer.stderr.on('data', (chunk) => process.stderr.write(chunk)) + await waitForUrl(targetUrl) + } +} + +function peerUrl(peerId) { + const url = new URL(targetUrl) + url.searchParams.set('room', `production-soak-${runId}`) + url.searchParams.set('peer', peerId) + return url.toString() +} + +async function applyNetwork(peer, profileName) { + const profile = networkProfiles[profileName] + + if (!profile) { + throw new Error(`Unknown network profile: ${profileName}`) + } + + await peer.cdp.send('Network.enable') + await peer.cdp.send('Network.emulateNetworkConditions', { + connectionType: profile.offline ? 'none' : 'cellular3g', + downloadThroughput: profile.downloadThroughput, + latency: profile.latency, + offline: profile.offline, + uploadThroughput: profile.uploadThroughput, + }) + peer.networkProfile = profileName + write({ type: 'network-profile', peer: peer.id, profileName, profile }) +} + +async function createPeer(peerId) { + const context = await browser.newContext({ + viewport: { height: 900, width: 1100 }, + }) + const page = await context.newPage() + const cdp = await context.newCDPSession(page) + const peer = { + cdp, + context, + id: peerId, + networkProfile: 'production', + page, + } + + page.on('console', (msg) => { + if (msg.type() === 'error') { + if ( + peer.networkProfile === 'offline' && + msg.text().includes('net::ERR_INTERNET_DISCONNECTED') + ) { + write({ + type: 'expected-offline-console', + messageType: msg.type(), + peer: peerId, + text: msg.text(), + }) + return + } + + metrics.consoleErrors += 1 + recordIssue( + 'console', + `peer-${peerId}`, + { messageType: msg.type(), text: msg.text() }, + 'error' + ) + } + }) + page.on('pageerror', (error) => { + metrics.pageErrors += 1 + recordIssue('pageerror', `peer-${peerId}`, { message: error.message }) + }) + + await applyNetwork(peer, 'production') + await page.goto(peerUrl(peerId), { waitUntil: 'domcontentloaded' }) + await page.locator(`[data-test-id="yjs-peer-${peerId}-append"]`).waitFor({ + timeout: 30_000, + }) + await page.waitForFunction( + () => document.querySelectorAll('[contenteditable="true"]').length === 1, + null, + { timeout: 30_000 } + ) + await pacedSleep(2) + + return peer +} + +async function click(peer, action, scenario) { + lastAction = { action, peer: peer.id, scenario } + const locator = peer.page + .locator(`[data-test-id="yjs-peer-${peer.id}-${action}"]`) + .first() + + if ((await locator.count()) === 0) { + recordIssue('missing-control', scenario, { action, peer: peer.id }) + return false + } + if (await locator.isDisabled()) { + write({ type: 'skip-disabled', action, peer: peer.id, scenario }) + await pacedSleep() + return false + } + + await locator.scrollIntoViewIfNeeded() + try { + await locator.click({ timeout: 10_000 }) + } catch (error) { + if (!String(error?.message ?? '').includes('')) { + throw error + } + + write({ + type: 'dev-overlay-click-fallback', + action, + peer: peer.id, + scenario, + }) + await locator.dispatchEvent('pointerdown', { + button: 0, + buttons: 1, + pointerId: 1, + pointerType: 'mouse', + }) + } + metrics.actions += 1 + write({ type: 'action', action, peer: peer.id, scenario }) + await pacedSleep() + return true +} + +async function snapshotPeer(peer) { + return await peer.page.evaluate(() => { + const root = document.querySelector('[contenteditable="true"]') + const blocks = root + ? Array.from( + root.querySelectorAll(':scope > [data-slate-node="element"]') + ).map((el) => ({ + childElementCount: el.querySelectorAll('[data-slate-node="element"]') + .length, + path: el.getAttribute('data-slate-path'), + text: el.textContent, + })) + : [] + + return { + blocks, + bodyText: document.body.textContent?.slice(0, 1000) ?? '', + cursorText: + document.querySelector('[data-test-id$="-cursors"]')?.textContent ?? '', + editorCount: document.querySelectorAll('[contenteditable="true"]').length, + nestedDivInP: document.querySelectorAll('[contenteditable="true"] p div') + .length, + nestedParagraphCount: document.querySelectorAll( + '[contenteditable="true"] p p' + ).length, + statusText: + document.body.textContent?.match( + /connected|connecting|disconnected|synced|syncing/g + ) ?? [], + url: location.href, + } + }) +} + +async function snapshot(peersById) { + const entries = await Promise.all( + peers.map(async (peerId) => [peerId, await snapshotPeer(peersById[peerId])]) + ) + + return Object.fromEntries(entries) +} + +function blockTexts(snap) { + return peers.map((peerId) => snap[peerId].blocks.map((block) => block.text)) +} + +function sameJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right) +} + +async function checkShape(peersById, scenario, label) { + const snap = await snapshot(peersById) + + for (const peerId of peers) { + const peerSnap = snap[peerId] + + if (peerSnap.editorCount !== 1) { + recordIssue('editor-count', scenario, { + editorCount: peerSnap.editorCount, + label, + peer: peerId, + }) + } + if (peerSnap.nestedParagraphCount > 0) { + recordIssue('nested-paragraph', scenario, { + blocks: blockTexts(snap), + label, + nestedParagraphCount: peerSnap.nestedParagraphCount, + peer: peerId, + }) + } + if (peerSnap.nestedDivInP > 0) { + recordIssue('nested-div-in-paragraph', scenario, { + blocks: blockTexts(snap), + label, + nestedDivInP: peerSnap.nestedDivInP, + peer: peerId, + }) + } + } + + return snap +} + +async function expectConverged(peersById, scenario, label, expected = null) { + const snap = await checkShape(peersById, scenario, label) + const texts = blockTexts(snap) + const first = texts[0] + const converged = texts.every((candidate) => sameJson(candidate, first)) + + if (!converged) { + recordIssue('non-convergence', scenario, { label, texts }) + } + if (expected && !sameJson(first, expected)) { + recordIssue('unexpected-document', scenario, { expected, label, texts }) + } + + return snap +} + +async function waitForConvergence( + peersById, + scenario, + label, + expected = null, + timeoutMs = 8000 +) { + const started = Date.now() + let lastSnap = null + + while (Date.now() - started < timeoutMs) { + lastSnap = await checkShape(peersById, scenario, label) + const texts = blockTexts(lastSnap) + const first = texts[0] + + if ( + texts.every((candidate) => sameJson(candidate, first)) && + (!expected || sameJson(first, expected)) + ) { + return lastSnap + } + + await sleep(250) + } + + return await expectConverged(peersById, scenario, label, expected) +} + +async function runScenario(name, fn) { + metrics.scenarios[name] = (metrics.scenarios[name] ?? 0) + 1 + write({ type: 'scenario-start', name }) + + try { + await fn(name) + } catch (error) { + recordIssue('scenario-exception', name, { + message: error?.message ?? String(error), + stack: String(error?.stack ?? '').slice(0, 2000), + }) + } finally { + write({ type: 'scenario-end', name }) + } +} + +async function reloadPeer(peer, scenario) { + metrics.hardReloads += 1 + write({ type: 'hard-reload', peer: peer.id, scenario }) + await peer.page.reload({ waitUntil: 'domcontentloaded' }) + await peer.page + .locator(`[data-test-id="yjs-peer-${peer.id}-append"]`) + .waitFor({ + timeout: 30_000, + }) + await pacedSleep(2) +} + +async function scenarioBaselineSplit(peersById, name) { + await click(peersById.b, 'split-node', name) + await waitForConvergence( + peersById, + name, + 'after b split', + metrics.scenarios[name] === 1 ? ['Hello ', 'world!'] : null + ) +} + +async function scenarioPersistenceReload(peersById, name) { + await click(peersById.a, 'append', name) + await waitForConvergence(peersById, name, 'after append') + await pacedSleep(8) + await reloadPeer(peersById.d, name) + await waitForConvergence(peersById, name, 'after d reload') +} + +async function scenarioBrowserNetworkPartition(peersById, name) { + await applyNetwork(peersById.c, 'offline') + metrics.browserOfflineWindows += 1 + await click(peersById.c, 'insert-text', name) + await click(peersById.a, 'append', name) + await pacedSleep(4) + await applyNetwork(peersById.c, 'degraded') + await click(peersById.c, 'connect', name) + await click(peersById.a, 'reconcile', name) + await waitForConvergence(peersById, name, 'after browser network restore') +} + +async function scenarioDegradedRandom(peersById, name) { + await applyNetwork(peersById.a, 'degraded') + await applyNetwork(peersById.d, 'degraded') + + for (let index = 0; index < 8; index += 1) { + const peer = peersById[peers[index % peers.length]] + const action = randomActions[index % randomActions.length] + await click(peer, action, name) + await checkShape(peersById, name, `random ${index}`) + } + + await applyNetwork(peersById.a, 'production') + await applyNetwork(peersById.d, 'production') + await click(peersById.a, 'reconcile', name) + await waitForConvergence(peersById, name, 'after degraded random') +} + +function writeSummary(final = false) { + const sortedIssues = [...issues.values()].sort((a, b) => { + const order = { error: 0, suspect: 1, warning: 2 } + return (order[a.severity] ?? 9) - (order[b.severity] ?? 9) + }) + const lines = [ + '# Yjs Hocuspocus Production Soak', + '', + `- status: ${final ? 'complete' : 'running'}`, + `- url: ${targetUrl}`, + `- yjs_url: ${yjsUrl}`, + `- run_id: ${runId}`, + `- elapsed_ms: ${Date.now() - startedAt}`, + `- actions: ${metrics.actions}`, + `- hard_reloads: ${metrics.hardReloads}`, + `- browser_offline_windows: ${metrics.browserOfflineWindows}`, + `- console_errors: ${metrics.consoleErrors}`, + `- page_errors: ${metrics.pageErrors}`, + `- issues: ${sortedIssues.length}`, + `- storage_dir: ${storageDir}`, + `- log: ${logPath}`, + '', + '## Network Profiles', + '', + ...Object.entries(networkProfiles).map( + ([name, profile]) => `- ${name}: ${JSON.stringify(profile)}` + ), + '', + '## Scenario Counts', + '', + ...Object.entries(metrics.scenarios).map( + ([name, count]) => `- ${name}: ${count}` + ), + '', + '## Issues', + '', + ...(sortedIssues.length === 0 + ? ['None recorded yet.'] + : sortedIssues.map((issue, index) => + [ + `### ${index + 1}. ${issue.kind}`, + '', + `- severity: ${issue.severity}`, + `- scenario: ${issue.scenario}`, + `- count: ${issue.count}`, + `- first_at: ${issue.firstAt}`, + `- last_at: ${issue.lastAt}`, + `- last_action: ${JSON.stringify(issue.lastAction)}`, + `- detail: ${JSON.stringify(issue.detail)}`, + '', + ].join('\n') + )), + '', + ] + + fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`) +} + +async function main() { + write({ + type: 'start', + config: { + actionDelayMs, + authTokenConfigured: Boolean(authToken), + durationMs, + jitterMs, + logPath, + outputRoot, + shouldStartServers, + storageDir, + summaryPath, + targetUrl, + yjsUrl, + }, + }) + + await startServers() + await waitForUrl(targetUrl) + browser = await chromium.launch({ + headless: process.env.SOAK_HEADLESS === '1', + }) + + const peerEntries = await Promise.all( + peers.map(async (peerId) => [peerId, await createPeer(peerId)]) + ) + const peersById = Object.fromEntries(peerEntries) + + await waitForConvergence(peersById, 'initial', 'initial convergence', [ + 'Hello world!', + ]) + scenarioStartedAt = Date.now() + + while (Date.now() - scenarioStartedAt < durationMs) { + await runScenario('baseline-split', (name) => + scenarioBaselineSplit(peersById, name) + ) + await runScenario('persistence-reload', (name) => + scenarioPersistenceReload(peersById, name) + ) + await runScenario('browser-network-partition', (name) => + scenarioBrowserNetworkPartition(peersById, name) + ) + await runScenario('degraded-random', (name) => + scenarioDegradedRandom(peersById, name) + ) + writeSummary(false) + } + + writeSummary(true) + write({ type: 'complete', issues: [...issues.values()], metrics }) + console.log(`[complete] summary=${summaryPath}`) + console.log(`[complete] log=${logPath}`) + + if (shouldFailOnIssues && issues.size > 0) { + process.exitCode = 1 + } +} + +async function cleanup() { + if (browser) { + await browser.close() + } + if (siteServer) { + siteServer.kill() + } + if (yjsServer) { + yjsServer.kill() + } +} + +main() + .catch((error) => { + recordIssue('runner-fatal', 'main', { + message: error?.message ?? String(error), + stack: String(error?.stack ?? '').slice(0, 4000), + }) + writeSummary(true) + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await cleanup() + }) diff --git a/scripts/yjs/hocuspocus-server.ts b/scripts/yjs/hocuspocus-server.ts new file mode 100644 index 0000000000..4cf783ff81 --- /dev/null +++ b/scripts/yjs/hocuspocus-server.ts @@ -0,0 +1,266 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { Logger as LoggerExtension } from '@hocuspocus/extension-logger' +import { Redis as RedisExtension } from '@hocuspocus/extension-redis' +import { + type Document as HocuspocusDocument, + type onAuthenticatePayload, + Server, +} from '@hocuspocus/server' +import * as Y from 'yjs' + +import { readSlateValueFromYjs } from '../../packages/slate-yjs/src/core/document' + +type CollabContext = { + documentName?: string + readOnly?: boolean +} + +type HeaderBag = Headers | Record + +const AUTHORIZATION_SPLIT_RE = /\s+/ + +const toNumber = (value: string | undefined, fallback: number): number => { + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : fallback +} + +const toBoolean = (value: string | undefined, fallback = false): boolean => { + if (value === undefined) { + return fallback + } + + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()) +} + +const YJS_PORT = toNumber(process.env.SLATE_YJS_PORT, 4444) +const YJS_HOST = process.env.SLATE_YJS_HOST ?? '0.0.0.0' +const YJS_PATH = process.env.SLATE_YJS_PATH ?? '/yjs' +const YJS_TIMEOUT = toNumber(process.env.SLATE_YJS_TIMEOUT, 10_000) +const YJS_DEBOUNCE = toNumber(process.env.SLATE_YJS_DEBOUNCE, 2000) +const YJS_MAX_DEBOUNCE = toNumber(process.env.SLATE_YJS_MAX_DEBOUNCE, 10_000) +const YJS_ROOT_NAME = process.env.SLATE_YJS_ROOT_NAME ?? 'slate' +const YJS_STORAGE_DIR = path.resolve( + process.cwd(), + process.env.SLATE_YJS_STORAGE_DIR ?? '.tmp/yjs-documents' +) +const YJS_AUTH_TOKEN = process.env.SLATE_YJS_AUTH_TOKEN +const YJS_READ_TOKEN = process.env.SLATE_YJS_READ_TOKEN +const YJS_ALLOW_ANONYMOUS_READ = toBoolean( + process.env.SLATE_YJS_ALLOW_ANONYMOUS_READ +) +const YJS_MAX_SNAPSHOT_BYTES = toNumber( + process.env.SLATE_YJS_MAX_SNAPSHOT_BYTES, + 10 * 1024 * 1024 +) +const YJS_WRITE_JSON_DEBUG = toBoolean( + process.env.SLATE_YJS_WRITE_JSON_DEBUG, + true +) +const REDIS_ENABLED = toBoolean(process.env.SLATE_YJS_REDIS_ENABLED) +const REDIS_HOST = process.env.SLATE_YJS_REDIS_HOST ?? '127.0.0.1' +const REDIS_PORT = toNumber(process.env.SLATE_YJS_REDIS_PORT, 6379) +const REDIS_USERNAME = process.env.SLATE_YJS_REDIS_USERNAME +const REDIS_PASSWORD = process.env.SLATE_YJS_REDIS_PASSWORD + +const markReadOnly = ( + context: CollabContext, + connectionConfig?: onAuthenticatePayload['connectionConfig'] +) => { + context.readOnly = true + + if (connectionConfig) { + connectionConfig.readOnly = true + } +} + +const getHeader = (headers: HeaderBag, key: string): string | undefined => { + if (headers instanceof Headers) { + return headers.get(key) ?? undefined + } + + const value = headers[key] ?? headers[key.toLowerCase()] + + if (Array.isArray(value)) { + return value.join(', ') + } + + return value +} + +const bearerToken = (headers: HeaderBag): string | undefined => { + const authorization = getHeader(headers, 'authorization') + + if (!authorization) { + return + } + + const [scheme, token] = authorization.split(AUTHORIZATION_SPLIT_RE, 2) + + return scheme?.toLowerCase() === 'bearer' ? token : undefined +} + +const sanitizeDocumentName = (documentName: string) => + documentName + .replaceAll(/[^a-zA-Z0-9._-]/g, '_') + .replaceAll(/^_+|_+$/g, '') + .slice(0, 160) || 'document' + +const snapshotPath = (documentName: string) => + path.join(YJS_STORAGE_DIR, `${sanitizeDocumentName(documentName)}.bin`) + +const jsonDebugPath = (documentName: string) => + path.join(YJS_STORAGE_DIR, `${sanitizeDocumentName(documentName)}.json`) + +const writeAtomic = async (targetPath: string, data: Uint8Array | string) => { + const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp` + + await writeFile(tempPath, data) + await rename(tempPath, targetPath) +} + +const loadSnapshot = async ( + document: HocuspocusDocument, + documentName: string +) => { + try { + const update = await readFile(snapshotPath(documentName)) + + Y.applyUpdate(document, update) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + +const storeSnapshot = async ( + document: HocuspocusDocument, + documentName: string +) => { + const update = Y.encodeStateAsUpdate(document) + + if (update.byteLength > YJS_MAX_SNAPSHOT_BYTES) { + throw new Error( + `Yjs snapshot is too large: ${update.byteLength} bytes exceeds ${YJS_MAX_SNAPSHOT_BYTES}` + ) + } + + await mkdir(YJS_STORAGE_DIR, { recursive: true }) + await writeAtomic(snapshotPath(documentName), update) + + if (YJS_WRITE_JSON_DEBUG) { + const root = document.get(YJS_ROOT_NAME, Y.XmlElement) + const value = readSlateValueFromYjs(root) + + await writeAtomic( + jsonDebugPath(documentName), + `${JSON.stringify( + { + documentName, + rootName: YJS_ROOT_NAME, + updatedAt: new Date().toISOString(), + value, + }, + null, + 2 + )}\n` + ) + } +} + +const extensions = [ + new LoggerExtension({ + log: (..._args: unknown[]) => {}, + onChange: false, + }), +] + +if (REDIS_ENABLED) { + extensions.push( + new RedisExtension({ + host: REDIS_HOST, + port: REDIS_PORT, + ...(REDIS_USERNAME || REDIS_PASSWORD + ? { + options: { + ...(REDIS_USERNAME ? { username: REDIS_USERNAME } : {}), + ...(REDIS_PASSWORD ? { password: REDIS_PASSWORD } : {}), + }, + } + : {}), + }) as never + ) +} + +const collabServer = new Server( + { + address: YJS_HOST, + debounce: YJS_DEBOUNCE, + extensions, + maxDebounce: YJS_MAX_DEBOUNCE, + name: 'slate-yjs-collab', + port: YJS_PORT, + quiet: true, + timeout: YJS_TIMEOUT, + onAuthenticate: async (payload) => { + payload.context ??= {} + const context = payload.context as CollabContext + const token = + payload.token || bearerToken(payload.requestHeaders as HeaderBag) + + context.documentName = payload.documentName + + if (!YJS_AUTH_TOKEN) { + return + } + + if (token === YJS_AUTH_TOKEN) { + return + } + + if ( + YJS_ALLOW_ANONYMOUS_READ || + (YJS_READ_TOKEN && token === YJS_READ_TOKEN) + ) { + markReadOnly(context, payload.connectionConfig) + + return + } + + throw new Error('Unauthorized') + }, + onLoadDocument: async ({ document, documentName }) => { + await loadSnapshot(document, documentName) + }, + onStoreDocument: async ({ context, document, documentName }) => { + if ((context as CollabContext | undefined)?.readOnly) { + return + } + + await storeSnapshot(document, documentName) + }, + }, + { + path: YJS_PATH, + } +) + +collabServer + .listen() + .then(() => { + const host = YJS_HOST === '0.0.0.0' ? '127.0.0.1' : YJS_HOST + + console.log( + `[slate-yjs] Hocuspocus listening at ws://${host}:${YJS_PORT}${YJS_PATH}` + ) + console.log(`[slate-yjs] snapshots: ${YJS_STORAGE_DIR}`) + console.log(`[slate-yjs] redis: ${REDIS_ENABLED ? 'enabled' : 'disabled'}`) + }) + .catch((error) => { + console.error('[slate-yjs] failed to start Hocuspocus server', error) + + throw error + }) diff --git a/site/constants/examples.ts b/site/constants/examples.ts index 71b55ec0db..021bed4f02 100644 --- a/site/constants/examples.ts +++ b/site/constants/examples.ts @@ -43,6 +43,7 @@ export const EXAMPLE_NAMES_AND_PATHS = [ ['Synced Blocks', 'synced-blocks', { badge: 'new' }], ['Tables', 'tables'], ['Yjs Collaboration', 'yjs-collaboration', { badge: 'new' }], + ['Yjs Hocuspocus', 'yjs-hocuspocus', { badge: 'new' }], ] as const satisfies readonly ExampleDefinition[] export const HIDDEN_EXAMPLES = [ diff --git a/site/examples/ts/yjs-hocuspocus.tsx b/site/examples/ts/yjs-hocuspocus.tsx new file mode 100644 index 0000000000..135747c96f --- /dev/null +++ b/site/examples/ts/yjs-hocuspocus.tsx @@ -0,0 +1,1532 @@ +import { HocuspocusProvider } from '@hocuspocus/provider' +import { + createYjsExtension, + type YjsAwarenessLike, + type YjsProviderEvent, + type YjsProviderEventHandler, + type YjsProviderLike, + type YjsProviderStatus, + type YjsTx, +} from '@slate/yjs' +import { + useYjsProviderStatus, + useYjsProviderSynced, + useYjsRemoteCursors, +} from '@slate/yjs/react' +import type { KeyboardEvent, MouseEvent, PointerEvent } from 'react' +import { useEffect, useState } from 'react' +import { type Descendant, NodeApi, type Operation, type Range } from 'slate' +import { history } from 'slate-history' +import { + Editable, + type RenderElementProps, + type RenderLeafProps, + Slate, + useSlateEditor, +} from 'slate-react' +import * as Y from 'yjs' + +import { Button } from '@/components/ui/button' +import { cn } from '@/utils/cn' + +import type { + CustomEditor, + CustomElement, + CustomText, + CustomValue, +} from './custom-types.d' + +type PeerId = 'a' | 'b' | 'c' | 'd' + +type PeerDefinition = { + appendText: string + clientId: number + color: string + id: PeerId + name: string + replacementText: string +} + +type KeyboardInputType = 'delete' | 'enter' | 'text' + +type SlateHocuspocusProvider = YjsProviderLike & { + hocuspocus: HocuspocusProvider +} + +type TextEntry = { + path: number[] + text: string +} + +const DEFAULT_YJS_URL = + process.env.NEXT_PUBLIC_SLATE_YJS_URL ?? 'ws://localhost:4444/yjs' +const DEFAULT_ROOM = + process.env.NEXT_PUBLIC_SLATE_YJS_ROOM ?? 'slate-yjs-hocuspocus-demo' +const DEFAULT_TOKEN = process.env.NEXT_PUBLIC_SLATE_YJS_TOKEN + +const INITIAL_VALUE: CustomValue = [ + { + type: 'paragraph', + children: [{ text: 'Hello world!' }], + }, +] + +const PEERS: PeerDefinition[] = [ + { + appendText: ' Ada', + clientId: 101, + color: '#2563eb', + id: 'a', + name: 'Ada', + replacementText: 'Ada canonical snapshot.', + }, + { + appendText: ' Lin', + clientId: 202, + color: '#dc2626', + id: 'b', + name: 'Lin', + replacementText: 'Lin canonical snapshot.', + }, + { + appendText: ' Ken', + clientId: 303, + color: '#16a34a', + id: 'c', + name: 'Ken', + replacementText: 'Ken canonical snapshot.', + }, + { + appendText: ' Eve', + clientId: 404, + color: '#9333ea', + id: 'd', + name: 'Eve', + replacementText: 'Eve canonical snapshot.', + }, +] as const + +const paragraph = (text: string): CustomElement => ({ + type: 'paragraph', + children: [{ text }], +}) + +const cloneValue = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const yjsTx = (tx: unknown) => (tx as { yjs: YjsTx }).yjs + +class HocuspocusProviderAdapter implements SlateHocuspocusProvider { + readonly awareness?: YjsAwarenessLike + readonly doc: Y.Doc + readonly hocuspocus: HocuspocusProvider + + status: YjsProviderStatus = 'connecting' + + constructor(options: { + clientId: number + name: string + token?: string + url: string + }) { + const doc = new Y.Doc() + + doc.clientID = options.clientId + + this.hocuspocus = new HocuspocusProvider({ + document: doc, + name: options.name, + token: options.token, + url: options.url, + }) + this.doc = doc + this.awareness = this.hocuspocus.awareness as YjsAwarenessLike | undefined + + this.hocuspocus.on( + 'status', + ({ status }: { status: YjsProviderStatus }) => { + this.status = status + } + ) + } + + get synced() { + return this.hocuspocus.synced + } + + connect() { + return this.hocuspocus.connect() + } + + destroy() { + this.hocuspocus.destroy() + } + + disconnect() { + return this.hocuspocus.disconnect() + } + + off(event: YjsProviderEvent, handler: YjsProviderEventHandler) { + this.hocuspocus.off(event, handler as never) + } + + on(event: YjsProviderEvent, handler: YjsProviderEventHandler) { + this.hocuspocus.on(event, handler as never) + } +} + +const createProvider = ( + peer: PeerDefinition, + roomName: string +): SlateHocuspocusProvider => + new HocuspocusProviderAdapter({ + clientId: peer.clientId, + name: roomName, + token: DEFAULT_TOKEN, + url: DEFAULT_YJS_URL, + }) + +const readInitialRoomName = () => { + if (typeof window === 'undefined') { + return DEFAULT_ROOM + } + + return new URLSearchParams(window.location.search).get('room') ?? DEFAULT_ROOM +} + +const readInitialPeers = (): readonly PeerDefinition[] => { + if (typeof window === 'undefined') { + return PEERS + } + + const peerId = new URLSearchParams(window.location.search).get('peer') + + if (!peerId) { + return PEERS + } + + return PEERS.filter((peer) => peer.id === peerId) +} + +const isCustomText = (node: Descendant): node is CustomText => 'text' in node + +const hasDescendantChildren = ( + node: Descendant +): node is Descendant & { children: readonly Descendant[] } => + 'children' in node && Array.isArray(node.children) + +const findFirstTextEntryInNode = ( + node: Descendant, + path: number[] +): TextEntry | null => { + if (isCustomText(node)) { + return { path, text: node.text } + } + + if (!hasDescendantChildren(node)) { + return null + } + + for (let index = 0; index < node.children.length; index++) { + const child = node.children[index] + + if (!child) { + continue + } + + const entry = findFirstTextEntryInNode(child, [...path, index]) + + if (entry) { + return entry + } + } + + return null +} + +const findLastTextEntryInNode = ( + node: Descendant, + path: number[] +): TextEntry | null => { + if (isCustomText(node)) { + return { path, text: node.text } + } + + if (!hasDescendantChildren(node)) { + return null + } + + for (let index = node.children.length - 1; index >= 0; index--) { + const child = node.children[index] + + if (!child) { + continue + } + + const entry = findLastTextEntryInNode(child, [...path, index]) + + if (entry) { + return entry + } + } + + return null +} + +const findLastTextEntry = ( + nodes: readonly Descendant[], + basePath: number[] = [] +): TextEntry | null => { + for (let index = nodes.length - 1; index >= 0; index--) { + const node = nodes[index] + + if (!node) { + continue + } + + const entry = findLastTextEntryInNode(node, [...basePath, index]) + + if (entry) { + return entry + } + } + + return null +} + +const getTextEntryAtPath = ( + nodes: readonly Descendant[], + path: number[] +): TextEntry | null => { + let current: Descendant | undefined + let children: readonly Descendant[] = nodes + + for (let depth = 0; depth < path.length; depth++) { + const index = path[depth] + + if (index === undefined) { + return null + } + + current = children[index] + + if (!current) { + return null + } + + if (isCustomText(current)) { + return depth === path.length - 1 ? { path, text: current.text } : null + } + + if (!hasDescendantChildren(current)) { + return null + } + + children = current.children + } + + return current && isCustomText(current) ? { path, text: current.text } : null +} + +const readEditorValue = (editor: CustomEditor): CustomValue => + editor.read((state) => + cloneValue(state.value.get().roots.main) + ) as CustomValue + +const getFirstBlockTextEntry = ( + editor: CustomEditor, + position: 'first' | 'last' +) => { + const [block] = readEditorValue(editor) + + if (!block) { + return null + } + + return position === 'first' + ? findFirstTextEntryInNode(block, [0]) + : findLastTextEntryInNode(block, [0]) +} + +const pointAtTextEnd = (entry: TextEntry) => ({ + path: entry.path, + offset: entry.text.length, +}) + +const readEditorSelection = (editor: CustomEditor) => + editor.read((state) => state.selection.get()) as Range | null + +const isCollapsedSelection = (selection: Range) => + selection.anchor.path.join('.') === selection.focus.path.join('.') && + selection.anchor.offset === selection.focus.offset + +const isSamePath = (left: readonly number[], right: readonly number[]) => + left.length === right.length && + left.every((part, index) => part === right[index]) + +const isSelectionAtTextEnd = (value: CustomValue, selection: Range) => { + if (!isCollapsedSelection(selection)) { + return false + } + + const entry = getTextEntryAtPath(value, selection.anchor.path) + + return entry ? selection.anchor.offset === entry.text.length : false +} + +const isSelectionAtDocumentEnd = (value: CustomValue, selection: Range) => { + if (!isCollapsedSelection(selection)) { + return false + } + + const entry = findLastTextEntry(value) + + return ( + !!entry && + isSamePath(selection.anchor.path, entry.path) && + selection.anchor.offset === entry.text.length + ) +} + +const normalizeHistorySelection = ( + value: CustomValue, + selection: Range | null, + options: { + preferDocumentEnd?: boolean | null + preferEndOfPreviousEndSelection?: Range | null + } = {} +): Range | null => { + const fallbackEntry = findLastTextEntry(value) + + if (options.preferDocumentEnd && fallbackEntry) { + const point = pointAtTextEnd(fallbackEntry) + + return { anchor: point, focus: point } + } + + if (options.preferEndOfPreviousEndSelection) { + const entry = + getTextEntryAtPath( + value, + options.preferEndOfPreviousEndSelection.anchor.path + ) ?? fallbackEntry + + if (entry) { + const point = pointAtTextEnd(entry) + + return { anchor: point, focus: point } + } + } + + if (!selection) { + if (!fallbackEntry) { + return null + } + + const point = pointAtTextEnd(fallbackEntry) + + return { anchor: point, focus: point } + } + + const anchorEntry = getTextEntryAtPath(value, selection.anchor.path) + const focusEntry = getTextEntryAtPath(value, selection.focus.path) + + if (!anchorEntry || !focusEntry) { + if (!fallbackEntry) { + return null + } + + const point = pointAtTextEnd(fallbackEntry) + + return { anchor: point, focus: point } + } + + return { + anchor: { + path: anchorEntry.path, + offset: Math.min(selection.anchor.offset, anchorEntry.text.length), + }, + focus: { + path: focusEntry.path, + offset: Math.min(selection.focus.offset, focusEntry.text.length), + }, + } +} + +const syncSelectionAfterHistory = ( + peer: PeerDefinition, + editor: CustomEditor, + previousValue: CustomValue, + previousSelection: Range | null +) => { + const value = readEditorValue(editor) + const selection = normalizeHistorySelection( + value, + readEditorSelection(editor), + { + preferDocumentEnd: + previousSelection && + isSelectionAtDocumentEnd(previousValue, previousSelection), + preferEndOfPreviousEndSelection: + previousSelection && + !isSelectionAtDocumentEnd(previousValue, previousSelection) && + isSelectionAtTextEnd(previousValue, previousSelection) + ? previousSelection + : null, + } + ) + + if (!selection) { + return + } + + editor.update((tx) => { + tx.selection.set(selection) + yjsTx(tx).sendSelection(selection, { + color: peer.color, + name: peer.name, + }) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const getParagraphCount = (editor: CustomEditor) => + editor.read((state) => state.nodes.children().length) + +const documentText = (editor: CustomEditor) => + readEditorValue(editor) + .map((node) => NodeApi.string(node)) + .join('\n') + +const selectedText = (editor: CustomEditor) => + editor.api.dom.getWindow().getSelection()?.toString().replaceAll('\uFEFF', '') + +const syncSelectionFromDom = (editor: CustomEditor) => { + const selection = editor.api.dom.getWindow().getSelection() + + if (!selection || selection.rangeCount === 0) { + return + } + + const range = editor.api.dom.resolveSlateRange(selection, { + exactMatch: false, + }) + + if (!range) { + return + } + + editor.update((tx) => { + tx.selection.set(range) + }) +} + +const runPeerCommand = ( + peer: PeerDefinition, + editor: CustomEditor, + command: (editor: CustomEditor) => void +) => { + syncSelectionFromDom(editor) + editor.api.history.withNewBatch(() => { + command(editor) + }) + editor.update((tx) => { + yjsTx(tx).sendCursorData({ + color: peer.color, + name: peer.name, + }) + }) +} + +const setConnected = (editor: CustomEditor, connected: boolean) => { + editor.update((tx) => { + if (connected) { + yjsTx(tx).connect() + } else { + yjsTx(tx).disconnect() + } + }) +} + +const selectHello = (peer: PeerDefinition, editor: CustomEditor) => { + const entry = getFirstBlockTextEntry(editor, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) + const range: Range = { + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, + } + + editor.update((tx) => { + tx.selection.set(range) + yjsTx(tx).sendSelection(range, { + color: peer.color, + name: peer.name, + }) + }) +} + +const appendText = (peer: PeerDefinition, editor: CustomEditor) => { + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry) { + return + } + + const offset = entry.text.length + peer.appendText.length + + editor.update((tx) => { + tx.text.insert(peer.appendText, { + at: { path: entry.path, offset: entry.text.length }, + }) + tx.selection.set({ + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, + }) + }) +} + +const insertExclamation = (editor: CustomEditor) => { + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry) { + return + } + + const offset = entry.text.length + 1 + + editor.update((tx) => { + tx.text.insert('!', { + at: { path: entry.path, offset: entry.text.length }, + }) + tx.selection.set({ + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, + }) + }) +} + +const selectDefaultBoldRange = (editor: CustomEditor) => { + const selection = editor.read((state) => + state.selection.get() + ) as Range | null + + if ( + selection && + (selection.anchor.path.join('.') !== selection.focus.path.join('.') || + selection.anchor.offset !== selection.focus.offset) + ) { + return + } + + const entry = getFirstBlockTextEntry(editor, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, + }) + }) +} + +const toggleBold = (editor: CustomEditor) => { + selectDefaultBoldRange(editor) + editor.update((tx) => { + tx.marks.toggle('bold') + }) +} + +const replaceDocument = (peer: PeerDefinition, editor: CustomEditor) => { + const value = readEditorValue(editor) + const selection = { + anchor: { path: [0, 0], offset: peer.replacementText.length }, + focus: { path: [0, 0], offset: peer.replacementText.length }, + } satisfies Range + + editor.update((tx) => { + tx.operations.replay([ + { + children: value, + index: 0, + newChildren: [paragraph(peer.replacementText)], + newSelection: selection, + path: [], + root: 'main', + selection: null, + type: 'replace_children', + }, + ]) + }) +} + +const replaceWithEmptyParagraph = (editor: CustomEditor) => { + const operation: Operation = { + children: readEditorValue(editor), + newChildren: [paragraph('')], + newSelection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + path: [], + root: 'main', + selection: null, + type: 'replace_fragment', + } + + editor.update((tx) => { + tx.operations.replay([operation]) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const replaceBlockTextWithEmpty = ( + editor: CustomEditor, + blockIndex: number +) => { + const value = readEditorValue(editor) + const block = value[blockIndex] + + if (!block || !('children' in block)) { + return + } + + const operation: Operation = { + children: block.children, + newChildren: [{ text: '' }], + newSelection: { + anchor: { path: [blockIndex, 0], offset: 0 }, + focus: { path: [blockIndex, 0], offset: 0 }, + }, + path: [blockIndex], + root: 'main', + selection: null, + type: 'replace_fragment', + } + + editor.update((tx) => { + tx.operations.replay([operation]) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const removeBlock = (editor: CustomEditor, blockIndex: number) => { + const value = readEditorValue(editor) + const node = value[blockIndex] + + if (!node) { + return + } + + if (value.length === 1) { + replaceWithEmptyParagraph(editor) + return + } + + editor.update((tx) => { + tx.operations.replay([ + { + node, + path: [blockIndex], + root: 'main', + type: 'remove_node', + }, + ]) + }) + editor.api.dom.focus({ retries: 1 }) +} + +const shouldReplaceWholeDocumentSelection = ( + event: KeyboardEvent, + editor: CustomEditor +) => { + if (event.key !== 'Backspace' && event.key !== 'Delete') { + return false + } + + const text = selectedText(editor) + + return !!text && text === documentText(editor) +} + +const selectedParagraphNodeIndex = ( + event: KeyboardEvent, + editor: CustomEditor +) => { + const datasetIndex = event.currentTarget.dataset.yjsSelectedParagraphNode + + if (datasetIndex) { + delete event.currentTarget.dataset.yjsSelectedParagraphNode + + return Number(datasetIndex) + } + + const selection = editor.api.dom.getWindow().getSelection() + + if (!selection || selection.rangeCount === 0) { + return -1 + } + + const range = selection.getRangeAt(0) + + if ( + range.startContainer !== event.currentTarget || + range.endContainer !== event.currentTarget || + range.endOffset - range.startOffset !== 1 + ) { + return -1 + } + + const selectedNode = event.currentTarget.childNodes[range.startOffset] + + return [...event.currentTarget.querySelectorAll('p')].indexOf( + selectedNode as HTMLParagraphElement + ) +} + +const selectedBlockTextIndex = (editor: CustomEditor) => { + const text = selectedText(editor) + + if (!text) { + return -1 + } + + return readEditorValue(editor).findIndex( + (node) => NodeApi.string(node) === text + ) +} + +const handleDeleteKeyDown = ( + event: KeyboardEvent, + peer: PeerDefinition, + editor: CustomEditor +) => { + if (!shouldReplaceWholeDocumentSelection(event, editor)) { + const nodeIndex = selectedParagraphNodeIndex(event, editor) + + if (nodeIndex !== -1) { + event.preventDefault() + runPeerCommand(peer, editor, () => removeBlock(editor, nodeIndex)) + + return true + } + + const blockIndex = selectedBlockTextIndex(editor) + + if (blockIndex === -1) { + return false + } + + event.preventDefault() + runPeerCommand(peer, editor, () => + replaceBlockTextWithEmpty(editor, blockIndex) + ) + + return true + } + + event.preventDefault() + runPeerCommand(peer, editor, replaceWithEmptyParagraph) + + return true +} + +const splitFirstText = (editor: CustomEditor, bumpRender: () => void) => { + const value = readEditorValue(editor) + const [block] = value + + if (!block) { + return + } + + const entry = findFirstTextEntryInNode(block, [0]) + + if (!entry || entry.text.length < 2) { + return + } + + const offset = Math.max(1, Math.floor(entry.text.length / 2)) + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset }, + focus: { path: entry.path, offset }, + }) + tx.break.insert() + }) + bumpRender() +} + +const wrapFirstBlock = (editor: CustomEditor) => { + editor.update((tx) => { + tx.selection.clear() + tx.nodes.wrap({ children: [], type: 'block-quote' }, { at: [0] }) + tx.selection.clear() + }) +} + +const ensureParagraphCount = (editor: CustomEditor, count: number) => { + const paragraphCount = getParagraphCount(editor) + + if (paragraphCount >= count) { + return + } + + editor.update((tx) => { + for (let index = paragraphCount; index < count; index++) { + tx.nodes.insert(paragraph(`block ${index + 1}`), { at: [index] }) + } + }) +} + +const removeSecondBlock = (editor: CustomEditor) => { + if (getParagraphCount(editor) < 2) { + return + } + + editor.update((tx) => { + tx.nodes.remove({ at: [1] }) + }) +} + +const mergeSecondBlock = (editor: CustomEditor, bumpRender: () => void) => { + if (getParagraphCount(editor) < 2) { + return + } + + editor.update((tx) => { + tx.nodes.merge({ at: [1] }) + }) + bumpRender() +} + +const moveFirstBlockDown = (editor: CustomEditor) => { + ensureParagraphCount(editor, 2) + + editor.update((tx) => { + tx.nodes.move({ at: [0], to: [1] }) + }) +} + +const setFirstBlockRole = (editor: CustomEditor) => { + editor.update((tx) => { + tx.nodes.set({ role: 'title' } as never, { at: [0] }) + }) +} + +const unsetFirstBlockRole = (editor: CustomEditor) => { + const [firstBlock] = readEditorValue(editor) + + if (!firstBlock || !('role' in firstBlock)) { + return + } + + editor.update((tx) => { + tx.nodes.unset('role' as never, { at: [0] }) + }) +} + +const firstBlockIsQuote = (editor: CustomEditor) => { + const [firstBlock] = readEditorValue(editor) + + return firstBlock && 'type' in firstBlock && firstBlock.type === 'block-quote' +} + +const unwrapFirstBlock = (editor: CustomEditor) => { + if (!firstBlockIsQuote(editor)) { + return + } + + editor.update((tx) => { + tx.nodes.unwrap({ at: [0] }) + }) +} + +const liftFirstWrappedBlock = (editor: CustomEditor) => { + if (!firstBlockIsQuote(editor)) { + return + } + + editor.update((tx) => { + tx.nodes.lift({ at: [0, 0] }) + }) +} + +const insertFragmentText = (peer: PeerDefinition, editor: CustomEditor) => { + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry) { + return + } + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, + }) + tx.fragment.insert([{ text: `${peer.name} fragment` }]) + }) +} + +const moveFirstBlockAfterSecond = (editor: CustomEditor) => { + if (getParagraphCount(editor) < 2) { + return + } + + editor.update((tx) => { + tx.nodes.move({ at: [0], to: [1] }) + }) +} + +const deleteFirstFragment = (editor: CustomEditor) => { + const entry = getFirstBlockTextEntry(editor, 'first') + + if (!entry) { + return + } + + const length = Math.min(5, entry.text.length) + + if (length === 0) { + return + } + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: 0 }, + focus: { path: entry.path, offset: length }, + }) + tx.fragment.delete() + }) +} + +const deleteBackwardFromFirstBlockEnd = (editor: CustomEditor) => { + const entry = getFirstBlockTextEntry(editor, 'last') + + if (!entry || entry.text.length === 0) { + return + } + + editor.update((tx) => { + tx.selection.set({ + anchor: { path: entry.path, offset: entry.text.length }, + focus: { path: entry.path, offset: entry.text.length }, + }) + tx.text.deleteBackward({ unit: 'character' }) + }) +} + +const undoPeer = (peer: PeerDefinition, editor: CustomEditor) => { + const previousValue = readEditorValue(editor) + const previousSelection = readEditorSelection(editor) + + editor.update((tx) => { + yjsTx(tx).undo() + }) + syncSelectionAfterHistory(peer, editor, previousValue, previousSelection) +} + +const redoPeer = (peer: PeerDefinition, editor: CustomEditor) => { + const previousValue = readEditorValue(editor) + const previousSelection = readEditorSelection(editor) + + editor.update((tx) => { + yjsTx(tx).redo() + }) + syncSelectionAfterHistory(peer, editor, previousValue, previousSelection) +} + +const handleHistoryKeyDown = ( + event: KeyboardEvent, + peer: PeerDefinition, + editor: CustomEditor +) => { + const isModifier = event.metaKey || event.ctrlKey + + if (!isModifier || event.key.toLowerCase() !== 'z') { + return false + } + + event.preventDefault() + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + + if (event.shiftKey) { + redoPeer(peer, editor) + } else { + undoPeer(peer, editor) + } + + return true +} + +const getKeyboardInputType = ( + event: KeyboardEvent +): KeyboardInputType | null => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return null + } + + if (event.key === 'Enter') { + return 'enter' + } + + if (event.key === 'Backspace' || event.key === 'Delete') { + return 'delete' + } + + return event.key.length === 1 ? 'text' : null +} + +const handleEditableKeyDown = ( + event: KeyboardEvent, + peer: PeerDefinition, + editor: CustomEditor +) => { + getKeyboardInputType(event) + + if (handleDeleteKeyDown(event, peer, editor)) { + return + } + + handleHistoryKeyDown(event, peer, editor) +} + +const handleCommandClick = ( + event: MouseEvent, + command: () => void +) => { + if (event.detail === 0) { + command() + } +} + +const handleCommandPointerDown = ( + event: PointerEvent, + command: () => void +) => { + event.preventDefault() + command() +} + +const Element = ({ + attributes, + children, + element, +}: RenderElementProps) => { + switch (element.type) { + case 'block-quote': + return ( +

+ {children} +
+ ) + default: + return

{children}

+ } +} + +const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => { + if (leaf.bold) { + children = {children} + } + + return {children} +} + +const CursorStatus = ({ editor }: { editor: CustomEditor }) => { + const cursors = useYjsRemoteCursors(editor) + + return ( + + {cursors.length === 0 + ? 'remote:none' + : cursors + .map((cursor) => { + const selection = cursor.selection + + if (!selection) { + return `${cursor.clientId}:null` + } + + return `${cursor.clientId}:${selection.anchor.path.join('.')}:${ + selection.anchor.offset + }-${selection.focus.path.join('.')}:${selection.focus.offset}` + }) + .join(' | ')} + + ) +} + +const CommandButton = ({ + children, + className, + disabled, + onRun, + testId, +}: { + children: string + className?: string + disabled?: boolean + onRun: () => void + testId: string +}) => ( + +) + +const ProviderBackedPeer = ({ + peer, + provider, +}: { + peer: PeerDefinition + provider: SlateHocuspocusProvider +}) => { + const editor = useSlateEditor({ + extensions: [ + history(), + createYjsExtension({ + clientId: peer.id, + provider, + rootName: 'slate', + seedProviderOnSync: peer.id === 'a', + }), + ], + initialValue: cloneValue(INITIAL_VALUE), + }) as CustomEditor + const [renderEpoch, setRenderEpoch] = useState(0) + const status = useYjsProviderStatus(editor) ?? provider.status + const synced = useYjsProviderSynced(editor) ?? provider.synced + const connected = status === 'connected' + const label = `Peer ${peer.id.toUpperCase()}` + const bumpRender = () => setRenderEpoch((current) => current + 1) + + useEffect(() => { + editor.update((tx) => { + yjsTx(tx).sendCursorData({ + color: peer.color, + name: peer.name, + }) + }) + }, [editor, peer.color, peer.name]) + + return ( + +
+
+
+

{label}

+
+ +
+
+
+ + {connected ? 'connected' : status} + + + {synced ? 'synced' : 'syncing'} + +
+
+ +
+ selectHello(peer, editor)} + testId={`yjs-peer-${peer.id}-select`} + > + Select + + + runPeerCommand(peer, editor, (editor) => toggleBold(editor)) + } + testId={`yjs-peer-${peer.id}-mark-bold`} + > + Bold + + setConnected(editor, false)} + testId={`yjs-peer-${peer.id}-disconnect`} + > + Offline + + setConnected(editor, true)} + testId={`yjs-peer-${peer.id}-connect`} + > + Online + + { + editor.update((tx) => { + yjsTx(tx).reconcile() + }) + }} + testId={`yjs-peer-${peer.id}-reconcile`} + > + Reconcile + + undoPeer(peer, editor)} + testId={`yjs-peer-${peer.id}-undo`} + > + Undo + + redoPeer(peer, editor)} + testId={`yjs-peer-${peer.id}-redo`} + > + Redo + +
+ +
+ + runPeerCommand(peer, editor, () => appendText(peer, editor)) + } + testId={`yjs-peer-${peer.id}-append`} + > + Append + + + runPeerCommand(peer, editor, () => replaceDocument(peer, editor)) + } + testId={`yjs-peer-${peer.id}-replace`} + > + Replace + + + runPeerCommand(peer, editor, () => removeSecondBlock(editor)) + } + testId={`yjs-peer-${peer.id}-remove-node`} + > + Remove + + + runPeerCommand(peer, editor, () => + splitFirstText(editor, bumpRender) + ) + } + testId={`yjs-peer-${peer.id}-split-node`} + > + Split + + + runPeerCommand(peer, editor, () => + mergeSecondBlock(editor, bumpRender) + ) + } + testId={`yjs-peer-${peer.id}-merge-node`} + > + Merge + + + runPeerCommand(peer, editor, () => moveFirstBlockDown(editor)) + } + testId={`yjs-peer-${peer.id}-move-down`} + > + Down + + + runPeerCommand(peer, editor, () => setFirstBlockRole(editor)) + } + testId={`yjs-peer-${peer.id}-set-node`} + > + Set Role + + + runPeerCommand(peer, editor, () => unsetFirstBlockRole(editor)) + } + testId={`yjs-peer-${peer.id}-unset-node`} + > + Unset Role + + + runPeerCommand(peer, editor, () => wrapFirstBlock(editor)) + } + testId={`yjs-peer-${peer.id}-wrap-node`} + > + Wrap + + + runPeerCommand(peer, editor, () => unwrapFirstBlock(editor)) + } + testId={`yjs-peer-${peer.id}-unwrap`} + > + Unwrap + + + runPeerCommand(peer, editor, () => liftFirstWrappedBlock(editor)) + } + testId={`yjs-peer-${peer.id}-lift`} + > + Lift + + + runPeerCommand(peer, editor, () => + insertFragmentText(peer, editor) + ) + } + testId={`yjs-peer-${peer.id}-insert-fragment`} + > + Fragment + + + runPeerCommand(peer, editor, () => deleteFirstFragment(editor)) + } + testId={`yjs-peer-${peer.id}-delete-fragment`} + > + Delete + + + runPeerCommand(peer, editor, () => + deleteBackwardFromFirstBlockEnd(editor) + ) + } + testId={`yjs-peer-${peer.id}-delete-backward`} + > + Back + + + runPeerCommand(peer, editor, () => insertExclamation(editor)) + } + testId={`yjs-peer-${peer.id}-insert-text`} + > + Insert ! + + + runPeerCommand(peer, editor, () => + moveFirstBlockAfterSecond(editor) + ) + } + testId={`yjs-peer-${peer.id}-move`} + > + Move + +
+ +
+ handleHistoryKeyDown(event, peer, editor) + } + > + handleEditableKeyDown(event, peer, editor)} + onSelect={() => { + editor.update((tx) => { + yjsTx(tx).sendSelection(undefined, { + color: peer.color, + name: peer.name, + }) + }) + }} + placeholder="Start typing" + renderElement={Element} + renderLeaf={Leaf} + spellCheck={false} + /> +
+
+
+ ) +} + +const ProviderPeer = ({ + peer, + roomName, +}: { + peer: PeerDefinition + roomName: string +}) => { + const [provider] = useState(() => createProvider(peer, roomName)) + + useEffect(() => { + return () => { + provider.destroy?.() + } + }, [provider]) + + return +} + +const YjsHocuspocusExample = () => { + const [roomName] = useState(readInitialRoomName) + const [peers] = useState(readInitialPeers) + + return ( +
+
+ {peers.map((peer) => ( + + ))} +
+
+ ) +} + +export default YjsHocuspocusExample diff --git a/site/pages/examples/[example].tsx b/site/pages/examples/[example].tsx index ce58c5e591..1725fe87cd 100644 --- a/site/pages/examples/[example].tsx +++ b/site/pages/examples/[example].tsx @@ -52,6 +52,7 @@ const EXAMPLE_IMPORTERS: Record< 'synced-blocks': () => import('../../examples/ts/synced-blocks'), tables: () => import('../../examples/ts/tables'), 'yjs-collaboration': () => import('../../examples/ts/yjs-collaboration'), + 'yjs-hocuspocus': () => import('../../examples/ts/yjs-hocuspocus'), } const EXAMPLES: ExampleTuple[] = EXAMPLE_NAMES_AND_PATHS.map(([name, path]) => [ From 9be6309aad708db8b84845ba70decde49f008cc2 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Thu, 11 Jun 2026 22:58:12 +0800 Subject: [PATCH 08/11] refactory --- .../src/editable/dom-repair-queue.ts | 6 +- packages/slate-yjs/src/core/attributes.ts | 85 ++ .../slate-yjs/src/core/awareness-adapter.ts | 199 ++++ packages/slate-yjs/src/core/awareness.ts | 39 +- packages/slate-yjs/src/core/controller.ts | 977 +++--------------- packages/slate-yjs/src/core/document.ts | 428 ++++---- packages/slate-yjs/src/core/editor-adapter.ts | 114 ++ packages/slate-yjs/src/core/editor-yjs.ts | 17 + packages/slate-yjs/src/core/extension.ts | 8 +- packages/slate-yjs/src/core/history.ts | 81 +- packages/slate-yjs/src/core/operations.ts | 673 +++--------- packages/slate-yjs/src/core/path.ts | 18 + .../src/core/provider-lifecycle-adapter.ts | 232 +++++ packages/slate-yjs/src/core/provider.ts | 80 +- packages/slate-yjs/src/core/record.ts | 4 + packages/slate-yjs/src/core/replacement.ts | 268 +++++ packages/slate-yjs/src/core/selection.ts | 31 +- .../src/core/split-history-adapter.ts | 370 +++++++ packages/slate-yjs/src/core/split-history.ts | 151 +-- packages/slate-yjs/src/core/text-delta.ts | 12 + packages/slate-yjs/src/core/types.ts | 182 ++-- .../src/core/undo-manager-adapter.ts | 71 +- packages/slate-yjs/src/react/index.ts | 377 ++++--- .../test/attributes-contract.spec.ts | 22 + .../slate-yjs/test/awareness-contract.spec.ts | 81 +- .../test/delete-fragment-contract.spec.ts | 85 +- .../test/document-id-contract.spec.ts | 37 + .../test/insert-fragment-contract.spec.ts | 110 +- .../test/lift-nodes-contract.spec.ts | 288 +++--- .../test/merge-node-contract.spec.ts | 150 ++- .../slate-yjs/test/move-node-contract.spec.ts | 139 +-- .../operation-exhaustiveness-contract.spec.ts | 8 +- .../test/package-config-contract.spec.ts | 198 +++- .../slate-yjs/test/provider-contract.spec.ts | 415 +++----- .../slate-yjs/test/react-contract.spec.tsx | 231 ++--- .../slate-yjs/test/record-contract.spec.ts | 12 + .../test/remove-node-contract.spec.ts | 83 +- .../test/replace-fragment-contract.spec.ts | 146 ++- .../slate-yjs/test/selection-contract.spec.ts | 94 +- .../slate-yjs/test/set-node-contract.spec.ts | 166 ++- .../test/simple-operations-contract.spec.ts | 135 +-- .../test/split-history-contract.spec.ts | 44 + .../test/split-merge-contract.spec.ts | 69 +- .../test/split-node-contract.spec.ts | 250 +++-- .../test/structural-soak-contract.spec.ts | 319 +++--- .../slate-yjs/test/support/collaboration.ts | 348 +++++-- packages/slate-yjs/test/support/provider.ts | 187 ++++ .../test/unwrap-nodes-contract.spec.ts | 133 +-- .../test/wrap-nodes-contract.spec.ts | 139 +-- 49 files changed, 4551 insertions(+), 3761 deletions(-) create mode 100644 packages/slate-yjs/src/core/attributes.ts create mode 100644 packages/slate-yjs/src/core/awareness-adapter.ts create mode 100644 packages/slate-yjs/src/core/editor-adapter.ts create mode 100644 packages/slate-yjs/src/core/editor-yjs.ts create mode 100644 packages/slate-yjs/src/core/path.ts create mode 100644 packages/slate-yjs/src/core/provider-lifecycle-adapter.ts create mode 100644 packages/slate-yjs/src/core/record.ts create mode 100644 packages/slate-yjs/src/core/replacement.ts create mode 100644 packages/slate-yjs/src/core/split-history-adapter.ts create mode 100644 packages/slate-yjs/src/core/text-delta.ts create mode 100644 packages/slate-yjs/test/attributes-contract.spec.ts create mode 100644 packages/slate-yjs/test/record-contract.spec.ts create mode 100644 packages/slate-yjs/test/split-history-contract.spec.ts create mode 100644 packages/slate-yjs/test/support/provider.ts diff --git a/packages/slate-react/src/editable/dom-repair-queue.ts b/packages/slate-react/src/editable/dom-repair-queue.ts index e4b1f9cf51..50bd2d6bd9 100644 --- a/packages/slate-react/src/editable/dom-repair-queue.ts +++ b/packages/slate-react/src/editable/dom-repair-queue.ts @@ -366,12 +366,12 @@ export const createDOMRepairQueue = ({ !!nativeInput.target && !!liveDOMPath && PathApi.equals(liveDOMPath, nativeInput.target.path) - const capturedInsertStillOwnsDOMSelection = + const capturedInsertStillOwnsDOMTarget = !!nativeInput.target?.preferCapturedInsert && - targetStillOwnsDOMSelection + targetPathStillOwnsDOMSelection const shouldMoveSelection = shouldReplaceExpandedSelection || - capturedInsertStillOwnsDOMSelection || + capturedInsertStillOwnsDOMTarget || !nativeInput.target || targetStillOwnsDOMSelection const shouldRepairCaretAfterTextInsert = diff --git a/packages/slate-yjs/src/core/attributes.ts b/packages/slate-yjs/src/core/attributes.ts new file mode 100644 index 0000000000..7460e16718 --- /dev/null +++ b/packages/slate-yjs/src/core/attributes.ts @@ -0,0 +1,85 @@ +import * as Y from 'yjs' + +export const SLATE_TYPE_ATTRIBUTE = 'slate:type' + +export type YjsNode = Y.XmlElement | Y.XmlText +export type YjsAttributeRecord = Record + +type YjsAttributeWriter = { + readonly setAttribute: (key: string, value: unknown) => void +} + +export const getYjsAttributes = (node: YjsNode): YjsAttributeRecord => + toYjsAttributeRecord(node.getAttributes()) + +export const hasYjsAttributes = (node: YjsNode): boolean => + Object.keys(getYjsAttributes(node)).length > 0 + +export const setYjsAttribute = ( + node: YjsNode, + key: string, + value: unknown +): void => { + // Yjs accepts JSON-ish attribute values at runtime; its XML declarations are narrower. + const writer = node as unknown as YjsAttributeWriter + + writer.setAttribute(key, value) +} + +export const toYjsAttributeRecord = ( + attributes: Readonly> +): YjsAttributeRecord => ({ ...attributes }) + +export const formatYjsTextAttributes = ( + text: Y.XmlText, + index: number, + length: number, + attributes: YjsAttributeRecord +): void => { + text.format(index, length, attributes) +} + +export const setYjsAttributes = ( + node: YjsNode, + attributes: YjsAttributeRecord +): void => { + for (const [key, value] of Object.entries(attributes)) { + setYjsAttribute(node, key, value) + } +} + +export const getSlateYjsElementType = (element: Y.XmlElement): string => + String(element.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? element.nodeName) + +export const setSlateYjsAttribute = ( + node: YjsNode, + key: string, + value: unknown +): void => { + if (key === 'type' && node instanceof Y.XmlElement) { + setYjsAttribute(node, SLATE_TYPE_ATTRIBUTE, String(value)) + + return + } + + setYjsAttribute(node, key, value) +} + +export const removeSlateYjsAttribute = (node: YjsNode, key: string): void => { + if (key === 'type' && node instanceof Y.XmlElement) { + node.removeAttribute(SLATE_TYPE_ATTRIBUTE) + + return + } + + node.removeAttribute(key) +} + +export const setSlateYjsAttributes = ( + node: YjsNode, + attributes: YjsAttributeRecord +): void => { + for (const [key, value] of Object.entries(attributes)) { + setSlateYjsAttribute(node, key, value) + } +} diff --git a/packages/slate-yjs/src/core/awareness-adapter.ts b/packages/slate-yjs/src/core/awareness-adapter.ts new file mode 100644 index 0000000000..c00da3564f --- /dev/null +++ b/packages/slate-yjs/src/core/awareness-adapter.ts @@ -0,0 +1,199 @@ +import type { Editor, Range } from 'slate' +import * as Y from 'yjs' + +import { + createYjsAwarenessSelection, + readYjsAwarenessSelection, + yjsAwarenessSelectionsEqual, +} from './awareness' +import { getYjsLength, getYjsNodeIf } from './document' +import { isRecord } from './record' +import type { + YjsAwarenessLike, + YjsAwarenessState, + YjsRemoteCursor, + YjsRemoteCursorData, +} from './types' + +type YjsAwarenessAdapterOptions = { + readonly awareness?: YjsAwarenessLike + readonly awarenessDataField: string + readonly awarenessSelectionField: string + readonly canSendSelection: () => boolean + readonly clientId: number | string + readonly doc: Y.Doc + readonly editor: Editor + readonly isConnected: () => boolean + readonly root: Y.XmlElement +} + +export type YjsAwarenessAdapter = { + readonly clearSelection: () => void + readonly currentSelection: () => Range | null + readonly remoteCursor: < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + >( + clientId: number + ) => YjsRemoteCursor | null + readonly remoteCursors: < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + >() => readonly YjsRemoteCursor[] + readonly sendCursorData: (data: YjsRemoteCursorData | null) => void + readonly sendSelection: ( + range?: Range | null, + data?: YjsRemoteCursorData | null + ) => void +} + +const getSortedAwarenessClientIds = ( + awareness: YjsAwarenessLike +): readonly number[] => [...awareness.getStates().keys()].sort((a, b) => a - b) + +const readRemoteCursorRecordData = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, +>( + state: YjsAwarenessState, + field: string +): TCursorData | undefined => { + const data = state[field] + + return isRecord(data) ? (data as TCursorData) : undefined +} + +export const createYjsAwarenessAdapter = ({ + awareness, + awarenessDataField, + awarenessSelectionField, + canSendSelection, + clientId, + doc, + editor, + isConnected, + root, +}: YjsAwarenessAdapterOptions): YjsAwarenessAdapter => { + const currentSelection = (): Range | null => + editor.read((state) => state.selection.get()) + + const getLocalAwarenessClientId = (): number => + awareness?.doc?.clientID ?? + awareness?.clientID ?? + (typeof clientId === 'number' ? clientId : doc.clientID) + + const isValidYjsSelectionPoint = (point: Range['anchor']): boolean => { + const node = getYjsNodeIf(root, point.path) + + return ( + node instanceof Y.XmlText && + point.offset >= 0 && + point.offset <= getYjsLength(node) + ) + } + + const sanitizeYjsSelection = (range: Range): Range | null => + ([range.anchor, range.focus] as const).every(isValidYjsSelectionPoint) + ? range + : null + + const clearSelection = (): void => { + if (awareness === undefined) { + return + } + + const localState = awareness.getLocalState() + + if ( + localState !== null && + awarenessSelectionField in localState && + localState[awarenessSelectionField] !== null + ) { + awareness.setLocalStateField(awarenessSelectionField, null) + } + } + + const remoteCursor = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + >( + remoteClientId: number + ): YjsRemoteCursor | null => { + if ( + awareness === undefined || + !isConnected() || + remoteClientId === getLocalAwarenessClientId() + ) { + return null + } + + const state = awareness.getStates().get(remoteClientId) + + if (state === undefined) { + return null + } + + const data = readRemoteCursorRecordData( + state, + awarenessDataField + ) + + return { + clientId: remoteClientId, + ...(data === undefined ? {} : { data }), + selection: readYjsAwarenessSelection( + root, + state[awarenessSelectionField] + ), + } + } + + const remoteCursors = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + >(): readonly YjsRemoteCursor[] => { + if (awareness === undefined || !isConnected()) { + return [] + } + + return getSortedAwarenessClientIds(awareness).flatMap((remoteClientId) => { + const cursor = remoteCursor(remoteClientId) + + return cursor === null ? [] : [cursor] + }) + } + + const sendCursorData = (data: YjsRemoteCursorData | null): void => { + awareness?.setLocalStateField(awarenessDataField, data) + } + + const sendSelection = ( + range: Range | null | undefined = currentSelection(), + data?: YjsRemoteCursorData | null + ): void => { + if (awareness === undefined || !canSendSelection()) { + return + } + + if (data !== undefined) { + sendCursorData(data) + } + + const nextRange = + range === null || range === undefined ? null : sanitizeYjsSelection(range) + const nextSelection = + nextRange === null ? null : createYjsAwarenessSelection(root, nextRange) + const currentAwarenessSelection = + awareness.getLocalState()?.[awarenessSelectionField] + + if ( + !yjsAwarenessSelectionsEqual(currentAwarenessSelection, nextSelection) + ) { + awareness.setLocalStateField(awarenessSelectionField, nextSelection) + } + } + + return { + clearSelection, + currentSelection, + remoteCursor, + remoteCursors, + sendCursorData, + sendSelection, + } +} diff --git a/packages/slate-yjs/src/core/awareness.ts b/packages/slate-yjs/src/core/awareness.ts index 571b03a4c4..08d483ef7f 100644 --- a/packages/slate-yjs/src/core/awareness.ts +++ b/packages/slate-yjs/src/core/awareness.ts @@ -1,8 +1,11 @@ import type { Range } from 'slate' import * as Y from 'yjs' +import { isRecord } from './record' import { slateRangeToYjsRelativeRange, + type YjsRelativeRange, + yjsRelativeRangesEqual, yjsRelativeRangeToSlateRange, } from './selection' import type { YjsAwarenessSelection } from './types' @@ -28,10 +31,10 @@ export const readYjsAwarenessSelection = ( } try { - return yjsRelativeRangeToSlateRange(root, { - anchor: Y.createRelativePositionFromJSON(value.anchor), - focus: Y.createRelativePositionFromJSON(value.focus), - }) + return yjsRelativeRangeToSlateRange( + root, + readYjsAwarenessRelativeRange(value) + ) } catch { return null } @@ -40,7 +43,7 @@ export const readYjsAwarenessSelection = ( export const yjsAwarenessSelectionsEqual = ( a: unknown, b: YjsAwarenessSelection | null -) => { +): boolean => { if (a === b) { return true } @@ -52,25 +55,23 @@ export const yjsAwarenessSelectionsEqual = ( } try { - return ( - Y.compareRelativePositions( - Y.createRelativePositionFromJSON(a.anchor), - Y.createRelativePositionFromJSON(b.anchor) - ) && - Y.compareRelativePositions( - Y.createRelativePositionFromJSON(a.focus), - Y.createRelativePositionFromJSON(b.focus) - ) - ) + const left = readYjsAwarenessRelativeRange(a) + const right = readYjsAwarenessRelativeRange(b) + + return yjsRelativeRangesEqual(left, right) } catch { return false } } +const readYjsAwarenessRelativeRange = ( + value: YjsAwarenessSelection +): YjsRelativeRange => ({ + anchor: Y.createRelativePositionFromJSON(value.anchor), + focus: Y.createRelativePositionFromJSON(value.focus), +}) + const isYjsAwarenessSelection = ( value: unknown ): value is YjsAwarenessSelection => - typeof value === 'object' && - value !== null && - 'anchor' in value && - 'focus' in value + isRecord(value) && 'anchor' in value && 'focus' in value diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts index 87e3d47d9f..6be34c66ce 100644 --- a/packages/slate-yjs/src/core/controller.ts +++ b/packages/slate-yjs/src/core/controller.ts @@ -3,30 +3,20 @@ import type { Editor, EditorCommit, EditorSnapshot, - Element, Operation, - Range, } from 'slate' -import { createEditor, NodeApi, OperationApi } from 'slate' -import { Editor as EditorApi } from 'slate/internal' import * as Y from 'yjs' import { - createYjsAwarenessSelection, - readYjsAwarenessSelection, - yjsAwarenessSelectionsEqual, -} from './awareness' + createYjsAwarenessAdapter, + type YjsAwarenessAdapter, +} from './awareness-adapter' import { - getYjsLength, - getYjsNode, - getYjsParent, - getYjsTextContent, readSlateValueFromYjs, removeRedundantEmptyYjsTextNodes, - removeYjsChild, replaceYjsChildren, - SPLIT_UNDO_TEXT_ATTRIBUTE, } from './document' +import { createYjsEditorAdapter, type YjsEditorAdapter } from './editor-adapter' import { removeRejectedYjsOperationsFromHistory, removeRejectedYjsOperationsFromHistoryAfterCommit, @@ -36,55 +26,50 @@ import { isNoopSlateOperationForYjs, } from './operations' import { - connectedFromYjsProviderStatus, - isPromiseLike, - normalizeYjsProviderStatus, - normalizeYjsProviderSynced, - readYjsProviderStatus, - readYjsProviderSynced, -} from './provider' + createYjsProviderLifecycleAdapter, + type YjsProviderLifecycleAdapter, +} from './provider-lifecycle-adapter' import { - appendElementText, - clearSplitUndoTextAttribute, - findSplitUndoTextRepairs, - getTrailingSplitUndoText, - getVisibleText, - getYjsNodeIf, - isSplitHistory, - nextPath, - type PendingTextSplitHistory, - pathsEqual, - SPLIT_HISTORY_META, - type SplitHistory, -} from './split-history' + createYjsSplitHistoryAdapter, + type YjsSplitHistoryAdapter, +} from './split-history-adapter' import type { YjsAwarenessChange, YjsAwarenessLike, YjsExtensionOptions, YjsProviderLike, - YjsProviderStatus, - YjsRemoteCursor, YjsState, YjsTraceEntry, YjsTx, } from './types' -import { - createYjsUndoManagerAdapter, - type YjsUndoManagerStackItem, -} from './undo-manager-adapter' +import { createYjsUndoManagerAdapter } from './undo-manager-adapter' -const remoteImportOptions = { - metadata: { - collab: { origin: 'remote', saveToHistory: false }, - history: { mode: 'skip' }, - selection: { dom: 'preserve', focus: false, scroll: false }, - }, - tag: ['collaboration', 'remote-yjs-import'], -} as const +const notifySubscribers = (subscribers: ReadonlySet<() => void>): void => { + for (const listener of subscribers) { + listener() + } +} + +const shouldSendCommitSelection = ( + commit: EditorCommit, + autoSendSelection: boolean +): boolean => + autoSendSelection && + commit.operations.some((operation) => operation.type === 'set_selection') + +const getYjsCommitOperations = ( + operations: readonly Operation[] +): Operation[] => + operations.filter( + (operation) => + operation.type !== 'set_selection' && + !isNoopSlateOperationForYjs(operation) + ) export class YjsController { private readonly autoSendSelection: boolean private readonly awareness?: YjsAwarenessLike + private readonly awarenessAdapter: YjsAwarenessAdapter private readonly awarenessDataField: string private readonly awarenessObserver: (event: YjsAwarenessChange) => void private readonly awarenessSelectionField: string @@ -93,6 +78,7 @@ export class YjsController { private readonly destroyProviderOnUnmount: boolean private readonly doc: Y.Doc private readonly editor: Editor + private readonly editorAdapter: YjsEditorAdapter private readonly canonicalizeOrigin = {} private readonly historyOrigin = {} private readonly localOrigin = {} @@ -102,32 +88,24 @@ export class YjsController { transaction: Y.Transaction ) => void private readonly provider?: YjsProviderLike + private readonly providerLifecycle: YjsProviderLifecycleAdapter private readonly providerOwnedDoc: boolean - private readonly providerStatusObserver: (status: unknown) => void - private readonly providerSubscribers = new Set<() => void>() - private readonly providerSyncedObserver: (synced: unknown) => void private readonly root: Y.XmlElement private readonly seedProviderOnSync: boolean private readonly traceEntries: YjsTraceEntry[] = [] private readonly undoManager: Y.UndoManager - private readonly undoManagerAdapter: ReturnType< - typeof createYjsUndoManagerAdapter - > + private readonly splitHistory: YjsSplitHistoryAdapter private awarenessRevision = 0 - private connected = true - private importing = false private paused = false - private pendingTextSplitHistory: PendingTextSplitHistory | null = null - private providerRevision = 0 - private providerStatusValue: YjsProviderStatus | null - private providerSyncedValue: boolean | null constructor(editor: Editor, options: YjsExtensionOptions) { this.editor = editor + this.editorAdapter = createYjsEditorAdapter(editor) this.provider = options.provider this.providerOwnedDoc = - !!this.provider && (!!options.doc || !!this.provider.doc) + this.provider !== undefined && + (options.doc !== undefined || this.provider.doc !== undefined) this.doc = options.doc ?? this.provider?.doc ?? new Y.Doc() this.root = this.doc.get(options.rootName ?? 'slate', Y.XmlElement) this.clientId = options.clientId ?? this.doc.clientID @@ -138,35 +116,39 @@ export class YjsController { this.awarenessSelectionField = options.awarenessSelectionField ?? 'selection' this.autoSendSelection = options.autoSendSelection ?? true - this.providerStatusValue = readYjsProviderStatus(this.provider) - this.providerSyncedValue = readYjsProviderSynced(this.provider) - this.connected = connectedFromYjsProviderStatus( - this.providerStatusValue, - this.connected - ) this.awarenessObserver = () => { this.updateAwarenessRevision() } - this.providerStatusObserver = (payload) => { - const status = normalizeYjsProviderStatus(payload) - - if (status) { - this.updateProviderStatus(status) - } - } - this.providerSyncedObserver = (payload) => { - const synced = - normalizeYjsProviderSynced(payload) ?? - readYjsProviderSynced(this.provider) - - if (synced !== null) { - this.updateProviderSynced(synced) - } - } + this.providerLifecycle = createYjsProviderLifecycleAdapter({ + onConnectedChange: () => this.updateAwarenessRevision(), + onProviderSyncedChange: () => this.reconcileProviderOwnedDocAfterSync(), + provider: this.provider, + }) this.undoManager = new Y.UndoManager(this.root, { trackedOrigins: new Set([this.localOrigin]), }) - this.undoManagerAdapter = createYjsUndoManagerAdapter(this.undoManager) + const undoManagerAdapter = createYjsUndoManagerAdapter(this.undoManager) + + this.splitHistory = createYjsSplitHistoryAdapter({ + doc: this.doc, + historyOrigin: this.historyOrigin, + isConnected: () => this.providerLifecycle.connected(), + root: this.root, + undoManagerAdapter, + }) + this.awarenessAdapter = createYjsAwarenessAdapter({ + awareness: this.awareness, + awarenessDataField: this.awarenessDataField, + awarenessSelectionField: this.awarenessSelectionField, + canSendSelection: () => + !this.shouldDeferProviderSeed() && + !this.shouldWaitForAppSeededProviderDoc(), + clientId: this.clientId, + doc: this.doc, + editor: this.editor, + isConnected: () => this.providerLifecycle.connected(), + root: this.root, + }) this.observer = (_events, transaction) => { if ( transaction.origin === this.localOrigin || @@ -188,19 +170,13 @@ export class YjsController { this.importFromYjs() } - this.awareness?.on?.('change', this.awarenessObserver) - this.provider?.on?.('status', this.providerStatusObserver) - this.provider?.on?.('sync', this.providerSyncedObserver) - this.provider?.on?.('synced', this.providerSyncedObserver) + this.bindExternalEvents() } - destroy() { - this.awareness?.off?.('change', this.awarenessObserver) - this.provider?.off?.('status', this.providerStatusObserver) - this.provider?.off?.('sync', this.providerSyncedObserver) - this.provider?.off?.('synced', this.providerSyncedObserver) - if (this.provider) { - this.clearSelection() + destroy(): void { + this.unbindExternalEvents() + if (this.provider !== undefined) { + this.awarenessAdapter.clearSelection() } if (this.destroyProviderOnUnmount) { this.provider?.destroy?.() @@ -209,38 +185,30 @@ export class YjsController { this.undoManager.destroy() } - handleCommit(commit: EditorCommit, snapshot: EditorSnapshot) { - if (this.importing || this.paused || !commit.snapshotChanged) { - return - } - if ( - commit.tags.includes('skip-collab') || - commit.tags.includes('collaboration') || - commit.metadata.collab?.origin === 'remote' - ) { - return - } - const shouldSendSelection = - this.autoSendSelection && - commit.operations.some((operation) => operation.type === 'set_selection') + private bindExternalEvents(): void { + this.awareness?.on?.('change', this.awarenessObserver) + this.providerLifecycle.bind() + } - if (!commit.snapshotChanged) { - if (shouldSendSelection) { - this.sendSelection(snapshot.selection) - } + private unbindExternalEvents(): void { + this.awareness?.off?.('change', this.awarenessObserver) + this.providerLifecycle.unbind() + } + handleCommit(commit: EditorCommit, snapshot: EditorSnapshot): void { + if (this.shouldSkipCommit(commit)) { return } - const operations = commit.operations.filter( - (operation) => - operation.type !== 'set_selection' && - !isNoopSlateOperationForYjs(operation) + const shouldSendSelection = shouldSendCommitSelection( + commit, + this.autoSendSelection ) + const operations = getYjsCommitOperations(commit.operations) if (operations.length === 0) { if (shouldSendSelection) { - this.sendSelection(snapshot.selection) + this.awarenessAdapter.sendSelection(snapshot.selection) } return @@ -248,20 +216,21 @@ export class YjsController { if (this.shouldRejectUnsafeProviderCommit()) { removeRejectedYjsOperationsFromHistory(this.editor, operations) - this.replaceEditorValue( - this.readChildrenBeforeOperations(operations), - commit.selectionBefore as Range | null + this.editorAdapter.replaceValue( + this.editorAdapter.readChildrenBeforeOperations(operations), + commit.selectionBefore ) - removeRejectedYjsOperationsFromHistory(this.editor, operations) - removeRejectedYjsOperationsFromHistoryAfterCommit(this.editor, operations) + this.removeRejectedOperationsFromHistory(operations) return } if (this.shouldSeedEmptyProviderDocForCommit()) { - this.seedValue(this.readChildrenBeforeOperations(operations)) + this.seedValue( + this.editorAdapter.readChildrenBeforeOperations(operations) + ) } - const splitHistory = this.createSplitHistory(operations) + const splitHistory = this.splitHistory.createFromOperations(operations) const rejectedLocalOperations: Operation[] = [] this.undoManager.stopCapturing() @@ -274,56 +243,61 @@ export class YjsController { } } }, this.localOrigin) - this.storeSplitHistory(splitHistory) + this.splitHistory.store(splitHistory) this.undoManager.stopCapturing() if (rejectedLocalOperations.length > 0) { - this.replaceEditorValue( + this.editorAdapter.replaceValue( readSlateValueFromYjs(this.root), snapshot.selection ) - removeRejectedYjsOperationsFromHistory( - this.editor, - rejectedLocalOperations - ) - removeRejectedYjsOperationsFromHistoryAfterCommit( - this.editor, - rejectedLocalOperations - ) + this.removeRejectedOperationsFromHistory(rejectedLocalOperations) } if (shouldSendSelection) { - this.sendSelection(snapshot.selection) + this.awarenessAdapter.sendSelection(snapshot.selection) } } - seed() { - if (this.root.length === 0) { - if (this.shouldSeedInitialProviderDoc()) { - this.seedInitialValue() - } - } else { - this.importFromYjs('seed') - } - + seed(): void { + this.seedInitialValueOrImportFromYjs(this.shouldSeedInitialProviderDoc()) this.root.observeDeep(this.observer) } + private shouldSkipCommit(commit: EditorCommit): boolean { + return ( + this.editorAdapter.importing() || + this.paused || + !commit.snapshotChanged || + commit.tags.includes('skip-collab') || + commit.tags.includes('collaboration') || + commit.metadata.collab?.origin === 'remote' + ) + } + + private removeRejectedOperationsFromHistory( + operations: readonly Operation[] + ): void { + removeRejectedYjsOperationsFromHistory(this.editor, operations) + removeRejectedYjsOperationsFromHistoryAfterCommit(this.editor, operations) + } + state(): YjsState { return { awarenessRevision: () => this.awarenessRevision, clientId: () => this.clientId, - connected: () => this.connected, + connected: () => this.providerLifecycle.connected(), doc: () => this.doc, paused: () => this.paused, - providerRevision: () => this.providerRevision, - providerStatus: () => this.providerStatusValue, - providerSynced: () => this.providerSyncedValue, - remoteCursor: (clientId) => this.remoteCursor(clientId), - remoteCursors: () => this.remoteCursors(), + providerRevision: () => this.providerLifecycle.providerRevision(), + providerStatus: () => this.providerLifecycle.providerStatus(), + providerSynced: () => this.providerLifecycle.providerSynced(), + remoteCursor: (clientId) => this.awarenessAdapter.remoteCursor(clientId), + remoteCursors: () => this.awarenessAdapter.remoteCursors(), root: () => this.root, subscribeAwareness: (listener) => this.subscribeAwareness(listener), - subscribeProvider: (listener) => this.subscribeProvider(listener), + subscribeProvider: (listener) => + this.providerLifecycle.subscribe(listener), trace: () => [...this.traceEntries], } } @@ -331,16 +305,16 @@ export class YjsController { tx(): YjsTx { return { clearSelection: () => { - this.clearSelection() + this.awarenessAdapter.clearSelection() }, clearTrace: () => { this.traceEntries.length = 0 }, connect: () => { - this.connect() + this.providerLifecycle.connect() }, disconnect: () => { - this.disconnect() + this.providerLifecycle.disconnect() }, pause: () => { this.paused = true @@ -349,10 +323,10 @@ export class YjsController { this.reconcile() }, reconnect: () => { - this.reconnect() + this.providerLifecycle.reconnect() }, redo: () => { - if (!this.redoSplit()) { + if (!this.splitHistory.redo()) { this.undoManager.redo() } }, @@ -360,20 +334,20 @@ export class YjsController { this.paused = false }, sendCursorData: (data) => { - this.sendCursorData(data) + this.awarenessAdapter.sendCursorData(data) }, sendSelection: (range, data) => { - this.sendSelection(range, data) + this.awarenessAdapter.sendSelection(range, data) }, undo: () => { - if (!this.undoSplit()) { + if (!this.splitHistory.undo()) { this.undoManager.undo() } }, } } - private subscribeAwareness(listener: () => void) { + private subscribeAwareness(listener: () => void): () => void { this.awarenessSubscribers.add(listener) return () => { @@ -381,146 +355,14 @@ export class YjsController { } } - private subscribeProvider(listener: () => void) { - this.providerSubscribers.add(listener) - - return () => { - this.providerSubscribers.delete(listener) - } - } - - private updateAwarenessRevision() { + private updateAwarenessRevision(): void { this.awarenessRevision += 1 - for (const listener of this.awarenessSubscribers) { - listener() - } - } - - private updateProviderRevision() { - this.providerRevision += 1 - - for (const listener of this.providerSubscribers) { - listener() - } - } - - private updateProviderStatus(status: YjsProviderStatus) { - this.updateConnectedFromProviderStatus(status) - - if (this.providerStatusValue === status) { - return - } - - this.providerStatusValue = status - this.updateProviderRevision() - } - - private updateConnectedFromProviderStatus(status: YjsProviderStatus) { - const connected = connectedFromYjsProviderStatus(status, this.connected) - - this.setConnected(connected) - } - - private syncProviderLifecycleStatus(fallbackConnected: boolean) { - const status = readYjsProviderStatus(this.provider) - - if (status) { - if (!fallbackConnected && status === 'connected') { - return - } - - this.updateProviderStatus(status) - - return - } - - if (this.providerStatusValue === null) { - this.setConnected(fallbackConnected) - } - } - - private setConnected(connected: boolean) { - if (this.connected === connected) { - return - } - - this.connected = connected - this.updateAwarenessRevision() - } - - private updateProviderSynced(synced: boolean) { - if (this.providerSyncedValue === synced) { - return - } - - this.providerSyncedValue = synced - this.reconcileProviderOwnedDocAfterSync() - this.updateProviderRevision() - } - - private connect() { - if (this.provider) { - const result = this.provider.connect?.() - - if (isPromiseLike(result)) { - void result.then( - () => { - this.syncProviderLifecycleStatus(true) - }, - () => undefined - ) - } else { - this.syncProviderLifecycleStatus(true) - } - - return result - } - - this.setConnected(true) + notifySubscribers(this.awarenessSubscribers) } - private disconnect() { - if (this.provider) { - this.setConnected(false) - const result = this.provider.disconnect?.() - - if (isPromiseLike(result)) { - void result.then( - () => { - this.syncProviderLifecycleStatus(false) - }, - () => undefined - ) - } else { - this.syncProviderLifecycleStatus(false) - } - - return result - } - - this.setConnected(false) - } - - private reconnect() { - const result = this.disconnect() - - if (isPromiseLike(result)) { - void result.then( - () => { - this.connect() - }, - () => undefined - ) - - return - } - - this.connect() - } - - private reconcile() { - if (this.providerOwnedDoc && this.root.length === 0) { + private reconcile(): void { + if (this.isProviderOwnedEmptyDoc()) { this.reconcileProviderOwnedDocAfterSync() return @@ -529,458 +371,103 @@ export class YjsController { this.importFromYjs() } - private shouldDeferProviderSeed() { + private shouldDeferProviderSeed(): boolean { return ( - this.providerOwnedDoc && - this.providerSyncedValue !== true && - this.root.length === 0 + this.isProviderOwnedEmptyDoc() && + this.providerLifecycle.providerSynced() !== true ) } - private shouldSeedEmptyProviderDocForCommit() { + private shouldSeedEmptyProviderDocForCommit(): boolean { return ( - this.providerOwnedDoc && + this.isProviderOwnedEmptyDoc() && this.seedProviderOnSync && - this.providerSyncedValue === true && - this.root.length === 0 + this.providerLifecycle.providerSynced() === true ) } - private shouldSeedInitialProviderDoc() { + private shouldSeedInitialProviderDoc(): boolean { return ( (!this.providerOwnedDoc || this.seedProviderOnSync) && !this.shouldDeferProviderSeed() ) } - private shouldRejectUnsafeProviderCommit() { + private shouldRejectUnsafeProviderCommit(): boolean { return ( - this.providerOwnedDoc && - this.root.length === 0 && - (!this.seedProviderOnSync || this.providerSyncedValue !== true) + this.isProviderOwnedEmptyDoc() && + (!this.seedProviderOnSync || + this.providerLifecycle.providerSynced() !== true) ) } - private shouldWaitForAppSeededProviderDoc() { - return this.providerOwnedDoc && this.root.length === 0 - } - - private readEditorChildren() { - return this.editor.read((state) => [ - ...state.value.get().roots.main, - ]) as Element[] + private shouldWaitForAppSeededProviderDoc(): boolean { + return this.isProviderOwnedEmptyDoc() } - private readChildrenBeforeOperations(operations: readonly Operation[]) { - const baselineEditor = createEditor() - - EditorApi.replace(baselineEditor, { - children: this.readEditorChildren(), - marks: null, - selection: null, - }) - baselineEditor.update((tx) => { - tx.operations.replay([...operations].reverse().map(OperationApi.inverse)) - }) - - return EditorApi.getSnapshot(baselineEditor).children as Element[] + private isProviderOwnedEmptyDoc(): boolean { + return this.providerOwnedDoc && this.root.length === 0 } - private seedInitialValue() { - this.seedValue(this.readEditorChildren()) + private seedInitialValue(): void { + this.seedValue(this.editorAdapter.readChildren()) } - private seedValue(children: Descendant[]) { + private seedValue(children: readonly Descendant[]): void { this.doc.transact(() => { replaceYjsChildren(this.root, children) }, this.seedOrigin) this.traceEntries.push({ mode: 'seed' }) } - private reconcileProviderOwnedDocAfterSync() { - if (!this.providerOwnedDoc || this.providerSyncedValue !== true) { - return - } - + private seedInitialValueOrImportFromYjs(seedWhenEmpty: boolean): void { if (this.root.length === 0) { - if (this.seedProviderOnSync) { + if (seedWhenEmpty) { this.seedInitialValue() } - } else { - this.importFromYjs('seed') - } - } - private clearSelection() { - if (!this.awareness) { return } - const localState = this.awareness.getLocalState() - - if ( - localState && - this.awarenessSelectionField in localState && - localState[this.awarenessSelectionField] !== null - ) { - this.awareness.setLocalStateField(this.awarenessSelectionField, null) - } - } - - private currentSelection(): Range | null { - return this.editor.read((state) => state.selection.get()) as Range | null - } - - private getLocalAwarenessClientId() { - return ( - this.awareness?.doc?.clientID ?? - this.awareness?.clientID ?? - (typeof this.clientId === 'number' ? this.clientId : this.doc.clientID) - ) + this.importFromYjs('seed') } - private remoteCursor< - TCursorData extends Record = Record, - >(clientId: number): YjsRemoteCursor | null { + private reconcileProviderOwnedDocAfterSync(): void { if ( - !this.awareness || - !this.connected || - clientId === this.getLocalAwarenessClientId() - ) { - return null - } - - const state = this.awareness.getStates().get(clientId) - - if (!state) { - return null - } - - const cursor: YjsRemoteCursor = { - clientId, - selection: readYjsAwarenessSelection( - this.root, - state[this.awarenessSelectionField] - ), - } - const data = state[this.awarenessDataField] - - if (data !== undefined) { - cursor.data = data as TCursorData - } - - return cursor - } - - private remoteCursors< - TCursorData extends Record = Record, - >(): YjsRemoteCursor[] { - if (!this.awareness || !this.connected) { - return [] - } - - return [...this.awareness.getStates().keys()] - .sort((a, b) => a - b) - .flatMap((clientId) => { - const cursor = this.remoteCursor(clientId) - - return cursor ? [cursor] : [] - }) - } - - private sendCursorData(data: Record | null) { - this.awareness?.setLocalStateField(this.awarenessDataField, data) - } - - private sendSelection( - range: Range | null | undefined = this.currentSelection(), - data?: Record | null - ) { - if (!this.awareness) { - return - } - if ( - this.shouldDeferProviderSeed() || - this.shouldWaitForAppSeededProviderDoc() + !this.providerOwnedDoc || + this.providerLifecycle.providerSynced() !== true ) { return } - if (data !== undefined) { - this.sendCursorData(data) - } - - const nextRange = range ? this.sanitizeYjsSelection(range) : null - const nextSelection = nextRange - ? createYjsAwarenessSelection(this.root, nextRange) - : null - const currentSelection = - this.awareness.getLocalState()?.[this.awarenessSelectionField] - - if (!yjsAwarenessSelectionsEqual(currentSelection, nextSelection)) { - this.awareness.setLocalStateField( - this.awarenessSelectionField, - nextSelection - ) - } - } - - private sanitizeYjsSelection(range: Range): Range | null { - for (const point of [range.anchor, range.focus]) { - const node = getYjsNodeIf(this.root, point.path) - - if ( - !(node instanceof Y.XmlText) || - point.offset < 0 || - point.offset > getYjsLength(node) - ) { - return null - } - } - - return range + this.seedInitialValueOrImportFromYjs(this.seedProviderOnSync) } - private applyOperation(operation: Operation) { + private applyOperation(operation: Operation): YjsTraceEntry | null { const trace = applySlateOperationToYjs(this.root, operation) - if (!trace) { + if (trace === null) { return null } this.traceEntries.push(trace) - if (trace.mode === 'unsupported') { - throw new Error(`Unsupported Yjs operation: ${operation.type}`) - } - return trace } - private shouldImportAfterLocalFallback(trace: YjsTraceEntry | null) { + private shouldImportAfterLocalFallback(trace: YjsTraceEntry | null): boolean { return ( trace?.mode === 'traceable-fallback' && trace.fallback === 'incompatible-structural-merge-elided' ) } - private createSplitHistory( - operations: readonly Operation[] - ): SplitHistory | null { - const textSplit = operations.find( - (operation): operation is Extract => { - if (operation.type !== 'split_node') { - return false - } - - try { - return getYjsNode(this.root, operation.path) instanceof Y.XmlText - } catch { - return false - } - } - ) - - const elementSplit = operations.find( - (operation): operation is Extract => - operation.type === 'split_node' && - !( - operation.path.length > 0 && - getYjsNodeIf(this.root, operation.path) instanceof Y.XmlText - ) - ) - - if (!textSplit) { - const pendingTextSplitHistory = this.pendingTextSplitHistory - - this.pendingTextSplitHistory = null - - if ( - elementSplit && - pendingTextSplitHistory && - pathsEqual(elementSplit.path, pendingTextSplitHistory.elementPath) - ) { - return { - ...pendingTextSplitHistory, - elementPosition: elementSplit.position, - elementProperties: elementSplit.properties as Record, - } - } - - return null - } - - const elementPath = textSplit.path.slice(0, -1) - const text = getYjsNode(this.root, textSplit.path) - - if (!(text instanceof Y.XmlText)) { - return null - } - - const pendingTextSplitHistory: PendingTextSplitHistory = { - elementPath, - rightText: getYjsTextContent(text).slice(textSplit.position), - textPath: textSplit.path, - textProperties: textSplit.properties as Record, - } - - if (!elementSplit || !pathsEqual(elementSplit.path, elementPath)) { - this.pendingTextSplitHistory = pendingTextSplitHistory - - return null - } - - this.pendingTextSplitHistory = null - - return { - ...pendingTextSplitHistory, - elementPosition: elementSplit.position, - elementProperties: elementSplit.properties as Record, - } - } - - private peekSplit(item: YjsUndoManagerStackItem | null): { - item: YjsUndoManagerStackItem - splitHistory: SplitHistory - } | null { - const splitHistory = item?.meta.get(SPLIT_HISTORY_META) - - if (!item || !isSplitHistory(splitHistory)) { - return null - } - - return { item, splitHistory } - } - - private redoSplit() { - const redo = this.peekSplit(this.undoManagerAdapter.peekRedo()) - - // Later redo items may still target the original right-side Yjs node. - // Let Yjs replay those split items natively so their identities survive. - if (!redo || this.undoManagerAdapter.redoDepth() > 1) { - return false - } - - if (redo.splitHistory.absorbedRemoteSplit) { - this.undoManagerAdapter.moveRedoToUndo(redo.item) - - return true - } - - this.doc.transact(() => { - const text = getYjsNode(this.root, redo.splitHistory.textPath) - - if (!(text instanceof Y.XmlText)) { - throw new Error('Cannot redo split_node because the text node is gone.') - } - - const textValue = getYjsTextContent(text) - - if (!textValue.endsWith(redo.splitHistory.rightText)) { - throw new Error( - 'Cannot redo split_node because the right text is no longer at the split boundary.' - ) - } - - const textPosition = textValue.length - redo.splitHistory.rightText.length - - applySlateOperationToYjs(this.root, { - path: redo.splitHistory.textPath, - position: textPosition, - properties: redo.splitHistory.textProperties, - type: 'split_node', - } as Operation) - applySlateOperationToYjs(this.root, { - path: redo.splitHistory.elementPath, - position: redo.splitHistory.elementPosition, - properties: redo.splitHistory.elementProperties, - type: 'split_node', - } as Operation) - }, this.historyOrigin) - - this.undoManagerAdapter.moveRedoToUndo(redo.item) - - return true - } - - private storeSplitHistory(splitHistory: SplitHistory | null) { - if (!splitHistory) { - return - } - - this.undoManagerAdapter.storeUndoMeta(SPLIT_HISTORY_META, splitHistory) - } - - private replaceEditorValue(children: Descendant[], selection: Range | null) { - const nextSelection = this.sanitizeImportSelection(children, selection) - - this.importing = true - - try { - this.editor.update((tx) => { - tx.value.replace({ - children, - marks: null, - selection: nextSelection, - }) - }, remoteImportOptions) - } finally { - this.importing = false - } - } - - private undoSplit() { - const undo = this.peekSplit(this.undoManagerAdapter.peekUndo()) - - // If another local edit was undone first, it can depend on the split-created - // right-side node. Native Yjs undo keeps that node redoable. - if (!undo || this.undoManagerAdapter.redoDepth() > 0) { - return false - } - - if (undo.splitHistory.absorbedRemoteSplit) { - this.undoManagerAdapter.moveUndoToRedo(undo.item) - - return true - } - - const undoneWhileDisconnected = !this.connected - let rightText = undo.splitHistory.rightText - - this.doc.transact(() => { - const leftText = getYjsNode(this.root, undo.splitHistory.textPath) - const rightElementPath = nextPath(undo.splitHistory.elementPath) - const rightElement = getYjsNode(this.root, rightElementPath) - const { index, parent } = getYjsParent(this.root, rightElementPath) - - if (!(leftText instanceof Y.XmlText)) { - throw new Error('Cannot undo split_node because the left text is gone.') - } - if (!(rightElement instanceof Y.XmlElement)) { - throw new Error( - 'Cannot undo split_node because the right element is gone.' - ) - } - - rightText = appendElementText(this.root, leftText, rightElement, { - [SPLIT_UNDO_TEXT_ATTRIBUTE]: undoneWhileDisconnected, - }) - removeYjsChild(this.root, parent, index) - }, this.historyOrigin) - - undo.splitHistory.rightText = rightText - undo.splitHistory.undoneWhileDisconnected = undoneWhileDisconnected - this.undoManagerAdapter.moveUndoToRedo(undo.item) - - return true - } - private importFromYjs( mode: YjsTraceEntry['mode'] = 'remote-reconcile', options: { repairRemoteSplitAfterOfflineUndo?: boolean } = {} - ) { + ): void { if (options.repairRemoteSplitAfterOfflineUndo ?? true) { - this.repairRemoteSplitAfterOfflineUndo() + this.splitHistory.repairAfterOfflineUndo() } this.doc.transact(() => { @@ -990,131 +477,9 @@ export class YjsController { const children = readSlateValueFromYjs(this.root) this.traceEntries.push({ mode }) - this.replaceEditorValue( + this.editorAdapter.replaceValue( children, - this.editor.read((state) => state.selection.get()) as Range | null + this.awarenessAdapter.currentSelection() ) } - - private sanitizeImportSelection( - children: Descendant[], - selection: Range | null - ) { - if (!selection) { - return null - } - - const root = { children } as Parameters[0] - - for (const point of [selection.anchor, selection.focus]) { - const node = NodeApi.getIf(root, point.path) - - if ( - !node || - !NodeApi.isText(node) || - point.offset < 0 || - point.offset > node.text.length - ) { - return null - } - } - - return selection - } - - private repairRemoteSplitAfterOfflineUndo() { - const repairs = findSplitUndoTextRepairs(this.root) - const redo = this.peekSplit(this.undoManagerAdapter.peekRedo()) - const splitHistory = redo?.splitHistory - const activeRepair = splitHistory?.undoneWhileDisconnected - ? this.getSplitUndoTextRepair(splitHistory) - : null - - if (repairs.length > 0) { - this.doc.transact(() => { - for (const repair of repairs) { - if (repair.hasRemoteSplitBoundary) { - repair.text.delete(repair.offset, repair.length) - } else { - clearSplitUndoTextAttribute( - repair.text, - repair.offset, - repair.length - ) - } - } - }, this.historyOrigin) - } - - if (!splitHistory?.undoneWhileDisconnected) { - return - } - - if ( - activeRepair?.hasRemoteSplitBoundary || - (!activeRepair && - this.hasRemoteSplitBoundary(splitHistory) && - !this.leftTextEndsWithSplitRightText(splitHistory)) - ) { - splitHistory.absorbedRemoteSplit = true - } else { - splitHistory.undoneWhileDisconnected = false - } - } - - private getSplitUndoTextRepair(splitHistory: SplitHistory) { - if (splitHistory.rightText.length === 0) { - return null - } - - try { - const leftText = getYjsNode(this.root, splitHistory.textPath) - - if (!(leftText instanceof Y.XmlText)) { - return null - } - - const trailing = getTrailingSplitUndoText(leftText) - - if (!trailing || trailing.value !== splitHistory.rightText) { - return null - } - - return { - ...trailing, - hasRemoteSplitBoundary: this.hasRemoteSplitBoundary(splitHistory), - text: leftText, - } as const - } catch { - return null - } - } - - private hasRemoteSplitBoundary(splitHistory: SplitHistory) { - try { - const rightElement = getYjsNode( - this.root, - nextPath(splitHistory.elementPath) - ) - - return getVisibleText(this.root, rightElement).startsWith( - splitHistory.rightText - ) - } catch { - return false - } - } - - private leftTextEndsWithSplitRightText(splitHistory: SplitHistory) { - try { - const leftText = getYjsNode(this.root, splitHistory.textPath) - - return ( - leftText instanceof Y.XmlText && - getYjsTextContent(leftText).endsWith(splitHistory.rightText) - ) - } catch { - return false - } - } } diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts index 403bf739b4..7e69041ad9 100644 --- a/packages/slate-yjs/src/core/document.ts +++ b/packages/slate-yjs/src/core/document.ts @@ -1,67 +1,76 @@ import type { Descendant, Path } from 'slate' import * as Y from 'yjs' -const SLATE_TYPE_ATTRIBUTE = 'slate:type' +import { + getSlateYjsElementType, + getYjsAttributes, + hasYjsAttributes, + SLATE_TYPE_ATTRIBUTE, + setYjsAttribute, + setYjsAttributes, + type YjsAttributeRecord, + type YjsNode, +} from './attributes' +import { + getYjsTextDeltaPartText, + isNonEmptyYjsTextDeltaPart, +} from './text-delta' + const HIDDEN_ATTRIBUTE = 'slate:yjs-hidden' const NODE_ID_ATTRIBUTE = 'slate:yjs-id' export const SPLIT_UNDO_TEXT_ATTRIBUTE = 'slate:yjs-split-undo-text' const VIRTUAL_CHILD_ID_ATTRIBUTE = 'slate:yjs-virtual-child-id' const VIRTUAL_PLACEHOLDER_ATTRIBUTE = 'slate:yjs-virtual-placeholder' +const VIRTUAL_YJS_CHILD_RAW_INDEX = -1 +const INTERNAL_YJS_ATTRIBUTES = [ + HIDDEN_ATTRIBUTE, + NODE_ID_ATTRIBUTE, + SPLIT_UNDO_TEXT_ATTRIBUTE, + VIRTUAL_CHILD_ID_ATTRIBUTE, + VIRTUAL_PLACEHOLDER_ATTRIBUTE, +] as const let nextNodeId = 0 const nodeIdScope = Math.random().toString(36).slice(2) -export const getYjsLength = (node: Y.XmlElement | Y.XmlText) => - (node as unknown as { length: number }).length +export const getYjsLength = (node: YjsNode): number => node.length -export const getYjsTextContent = (node: Y.XmlText) => - node - .toDelta() - .map((part: { insert: unknown }) => - typeof part.insert === 'string' ? part.insert : '' - ) - .join('') +export const getYjsTextContent = (node: Y.XmlText): string => + node.toDelta().map(getYjsTextDeltaPartText).join('') -const getAttributes = (node: Y.XmlElement | Y.XmlText) => - ( - node as unknown as { getAttributes(): Record } - ).getAttributes() +const isYjsContentNode = (value: unknown): value is YjsNode => + value instanceof Y.XmlElement || value instanceof Y.XmlText -const setAttributes = ( - node: Y.XmlElement | Y.XmlText, - attributes: Record -) => { - for (const [key, value] of Object.entries(attributes)) { - node.setAttribute(key, value as never) - } -} +const getRawYjsChildren = (node: Y.XmlElement): YjsNode[] => + node.toArray().filter((child): child is YjsNode => isYjsContentNode(child)) -const getRawYjsChildren = (node: Y.XmlElement) => - node - .toArray() - .filter( - (child): child is Y.XmlElement | Y.XmlText => - child instanceof Y.XmlElement || child instanceof Y.XmlText - ) +const isHiddenYjsNode = (node: YjsNode): boolean => + getYjsAttributes(node)[HIDDEN_ATTRIBUTE] === true -const isHiddenYjsNode = (node: Y.XmlElement | Y.XmlText) => - getAttributes(node)[HIDDEN_ATTRIBUTE] === true +const isEmptyAttributeFreeYjsText = (node: YjsNode): boolean => + node instanceof Y.XmlText && + getYjsTextContent(node).length === 0 && + !hasYjsAttributes(node) -const removeAttribute = (node: Y.XmlElement | Y.XmlText, attribute: string) => { - node.removeAttribute(attribute) +type YjsVisibleChildSlot = { + readonly node: YjsNode + readonly rawIndex: number } -export const isVirtualYjsPlaceholder = ( - node: Y.XmlElement | Y.XmlText -): node is Y.XmlElement => +type YjsChildRemovalMode = 'hidden' | 'hidden-parent' | 'visible' + +const isVirtualYjsPlaceholder = (node: YjsNode): boolean => node instanceof Y.XmlElement && - getAttributes(node)[VIRTUAL_PLACEHOLDER_ATTRIBUTE] === true + getYjsAttributes(node)[VIRTUAL_PLACEHOLDER_ATTRIBUTE] === true + +const hasRawYjsChildSlot = (slot: YjsVisibleChildSlot): boolean => + slot.rawIndex !== VIRTUAL_YJS_CHILD_RAW_INDEX -export const getVirtualYjsChild = ( +const getVirtualYjsChild = ( root: Y.XmlElement, node: Y.XmlElement, visited = new Set() -): Y.XmlElement | Y.XmlText | null => { +): YjsNode | null => { if (visited.has(node)) { return null } @@ -86,16 +95,19 @@ export const getVirtualYjsChild = ( return null } -const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { +const getYjsVisibleChildSlots = ( + root: Y.XmlElement, + node: Y.XmlElement +): YjsVisibleChildSlot[] => { const rawSlots = getRawYjsChildren(node).flatMap((child, rawIndex) => { if (isHiddenYjsNode(child)) { return [] } - if (isVirtualYjsPlaceholder(child)) { + if (child instanceof Y.XmlElement && isVirtualYjsPlaceholder(child)) { const virtualChild = getVirtualYjsChild(root, child) - return virtualChild ? [{ node: virtualChild, rawIndex }] : [] + return virtualChild === null ? [] : [{ node: virtualChild, rawIndex }] } return [{ node: child, rawIndex }] @@ -104,28 +116,33 @@ const getYjsVisibleChildSlots = (root: Y.XmlElement, node: Y.XmlElement) => { if (!isVirtualYjsPlaceholder(node)) { const virtualChild = getVirtualYjsChild(root, node) - if (virtualChild) { - return [{ node: virtualChild, rawIndex: -1 }, ...rawSlots] + if (virtualChild !== null) { + return [ + { node: virtualChild, rawIndex: VIRTUAL_YJS_CHILD_RAW_INDEX }, + ...rawSlots, + ] } } return rawSlots } -export const getYjsChildren = (node: Y.XmlElement) => +export const getYjsChildren = (node: Y.XmlElement): YjsNode[] => getRawYjsChildren(node).filter((child) => !isHiddenYjsNode(child)) -export const getYjsVisibleChildren = (root: Y.XmlElement, node: Y.XmlElement) => - getYjsVisibleChildSlots(root, node).map((slot) => slot.node) +export const getYjsVisibleChildren = ( + root: Y.XmlElement, + node: Y.XmlElement +): YjsNode[] => getYjsVisibleChildSlots(root, node).map((slot) => slot.node) export const getYjsVisiblePath = ( root: Y.XmlElement, - target: Y.XmlElement | Y.XmlText + target: YjsNode ): Path | null => { const visit = ( - node: Y.XmlElement | Y.XmlText, + node: YjsNode, path: Path, - visited: Set + visited: Set ): Path | null => { if (node === target) { return path @@ -138,10 +155,10 @@ export const getYjsVisiblePath = ( const children = getYjsVisibleChildren(root, node) - for (let index = 0; index < children.length; index++) { - const childPath = visit(children[index]!, [...path, index], visited) + for (const [index, child] of children.entries()) { + const childPath = visit(child, [...path, index], visited) - if (childPath) { + if (childPath !== null) { return childPath } } @@ -152,39 +169,50 @@ export const getYjsVisiblePath = ( return visit(root, [], new Set()) } -export const createYjsNode = (node: Descendant): Y.XmlElement | Y.XmlText => { +export const createYjsText = ( + text: string, + attributes: YjsAttributeRecord +): Y.XmlText => { + const yjsText = new Y.XmlText() + + setYjsAttributes(yjsText, attributes) + + if (text.length > 0) { + yjsText.insert(0, text, attributes) + } + + return yjsText +} + +export const createYjsNode = (node: Descendant): YjsNode => { if ('text' in node) { - const text = new Y.XmlText() const { text: value, ...attributes } = node const stringValue = String(value) - const textAttributes = attributes as Record - - setAttributes(text, textAttributes) - - if (stringValue.length > 0) { - text.insert(0, stringValue, textAttributes) - } - return text + return createYjsText(stringValue, attributes) } - const element = new Y.XmlElement(String(node.type ?? 'element')) const { children, type, ...attributes } = node + const elementType = String(type ?? 'element') + const element = new Y.XmlElement(elementType) - element.setAttribute(SLATE_TYPE_ATTRIBUTE, String(type)) - setAttributes(element, attributes) + setYjsAttribute(element, SLATE_TYPE_ATTRIBUTE, elementType) + setYjsAttributes(element, attributes) if (children.length > 0) { - element.insert(0, children.map(createYjsNode)) + element.insert(0, createYjsNodes(children)) } return element } +export const createYjsNodes = (nodes: readonly Descendant[]): YjsNode[] => + nodes.map(createYjsNode) + export const replaceYjsChildren = ( parent: Y.XmlElement, children: readonly Descendant[] -) => { +): void => { const length = getYjsLength(parent) if (length > 0) { @@ -192,7 +220,7 @@ export const replaceYjsChildren = ( } if (children.length > 0) { - parent.insert(0, children.map(createYjsNode)) + parent.insert(0, createYjsNodes(children)) } } @@ -206,8 +234,8 @@ export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => { : [{ children: [{ text: '' }], type: 'paragraph' }] } -export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement) => { - const visit = (parent: Y.XmlElement) => { +export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement): void => { + const visit = (parent: Y.XmlElement): void => { for (const child of getRawYjsChildren(parent)) { if (child instanceof Y.XmlElement) { visit(child) @@ -221,15 +249,15 @@ export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement) => { } for (let index = visibleSlots.length - 1; index >= 0; index--) { - const slot = visibleSlots[index]! + const slot = visibleSlots[index] + + if (slot === undefined) { + continue + } + const child = slot.node - if ( - slot.rawIndex >= 0 && - child instanceof Y.XmlText && - getYjsTextContent(child).length === 0 && - Object.keys(getAttributes(child)).length === 0 - ) { + if (hasRawYjsChildSlot(slot) && isEmptyAttributeFreeYjsText(child)) { parent.delete(slot.rawIndex, 1) } } @@ -238,20 +266,18 @@ export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement) => { visit(root) } -const getUniformTextAttributes = (node: Y.XmlText) => { +const getUniformTextAttributes = (node: Y.XmlText): YjsAttributeRecord => { const delta = node.toDelta() - let attributes: Record | undefined + let attributes: YjsAttributeRecord | undefined for (const part of delta) { - if (typeof part.insert !== 'string' || part.insert.length === 0) { + if (!isNonEmptyYjsTextDeltaPart(part)) { continue } - const partAttributes = { ...(part.attributes ?? {}) } + const partAttributes = getPublicAttributes(part.attributes) - deleteInternalAttributes(partAttributes) - - if (!attributes) { + if (attributes === undefined) { attributes = partAttributes continue } @@ -271,14 +297,35 @@ const getUniformTextAttributes = (node: Y.XmlText) => { return attributes ?? {} } +const getPublicAttributes = ( + attributes?: Readonly +): YjsAttributeRecord => { + const publicAttributes = { ...(attributes ?? {}) } + + deleteInternalAttributes(publicAttributes) + + return publicAttributes +} + +const getPublicYjsAttributes = (node: YjsNode): YjsAttributeRecord => + getPublicAttributes(getYjsAttributes(node)) + +const getPublicYjsElementAttributes = ( + node: Y.XmlElement +): YjsAttributeRecord => { + const attributes = getPublicYjsAttributes(node) + + delete attributes[SLATE_TYPE_ATTRIBUTE] + + return attributes +} + const readSlateNodeFromYjs = ( root: Y.XmlElement, - node: Y.XmlElement | Y.XmlText + node: YjsNode ): Descendant => { - const attributes = { ...getAttributes(node) } - if (node instanceof Y.XmlText) { - deleteInternalAttributes(attributes) + const attributes = getPublicYjsAttributes(node) return { ...attributes, @@ -287,40 +334,38 @@ const readSlateNodeFromYjs = ( } } - const type = attributes[SLATE_TYPE_ATTRIBUTE] ?? node.nodeName + const attributes = getPublicYjsElementAttributes(node) + const type = getSlateYjsElementType(node) - delete attributes[SLATE_TYPE_ATTRIBUTE] - deleteInternalAttributes(attributes) - - const children = getYjsVisibleChildren(root, node).map((child) => - readSlateNodeFromYjs(root, child) + const children: Descendant[] = getYjsVisibleChildren(root, node).map( + (child) => readSlateNodeFromYjs(root, child) ) return { ...attributes, type, children: children.length > 0 ? children : [{ text: '' }], - } as Descendant + } } const cloneYjsNodeWithRoot = ( - node: Y.XmlElement | Y.XmlText, - root: Y.XmlElement | null -): Y.XmlElement | Y.XmlText | null => { - if (root && node instanceof Y.XmlElement && isVirtualYjsPlaceholder(node)) { + node: YjsNode, + root: Y.XmlElement +): YjsNode | null => { + if (node instanceof Y.XmlElement && isVirtualYjsPlaceholder(node)) { const virtualChild = getVirtualYjsChild(root, node) - return virtualChild ? cloneYjsNodeWithRoot(virtualChild, root) : null + return virtualChild === null + ? null + : cloneYjsNodeWithRoot(virtualChild, root) } - const attributes = { ...getAttributes(node) } - - deleteInternalAttributes(attributes) + const attributes = getPublicYjsAttributes(node) if (node instanceof Y.XmlText) { const clone = new Y.XmlText() - setAttributes(clone, attributes) + setYjsAttributes(clone, attributes) clone.applyDelta(node.toDelta(), { sanitize: false }) return clone @@ -328,20 +373,12 @@ const cloneYjsNodeWithRoot = ( const clone = new Y.XmlElement(node.nodeName) const children = getYjsChildren(node).flatMap((child) => { - if ( - !root && - child instanceof Y.XmlElement && - isVirtualYjsPlaceholder(child) - ) { - return [] - } - const childClone = cloneYjsNodeWithRoot(child, root) - return childClone ? [childClone] : [] + return childClone === null ? [] : [childClone] }) - setAttributes(clone, attributes) + setYjsAttributes(clone, attributes) if (children.length > 0) { clone.insert(0, children) @@ -350,40 +387,29 @@ const cloneYjsNodeWithRoot = ( return clone } -export const cloneYjsNode = ( - node: Y.XmlElement | Y.XmlText -): Y.XmlElement | Y.XmlText => { - const clone = cloneYjsNodeWithRoot(node, null) - - if (!clone) { - throw new Error('Cannot clone a missing Yjs node.') - } - - return clone -} - -export const cloneVisibleYjsNode = ( +export const cloneVisibleYjsNodes = ( root: Y.XmlElement, - node: Y.XmlElement | Y.XmlText -): Y.XmlElement | Y.XmlText | null => cloneYjsNodeWithRoot(node, root) + nodes: readonly YjsNode[] +): YjsNode[] => + nodes.flatMap((node) => { + const clone = cloneYjsNodeWithRoot(node, root) -export const getYjsNode = ( - root: Y.XmlElement, - path: Path -): Y.XmlElement | Y.XmlText => { - let current: Y.XmlElement | Y.XmlText = root + return clone === null ? [] : [clone] + }) + +export const getYjsNode = (root: Y.XmlElement, path: Path): YjsNode => { + let current: YjsNode = root for (const index of path) { if (current instanceof Y.XmlText) { throw new Error(`Cannot descend into Y.XmlText at path ${path.join('.')}`) } - const child: Y.XmlElement | Y.XmlText | undefined = getYjsVisibleChildren( - root, - current - )[index] + const child: YjsNode | undefined = getYjsVisibleChildren(root, current)[ + index + ] - if (!(child instanceof Y.XmlElement) && !(child instanceof Y.XmlText)) { + if (!isYjsContentNode(child)) { throw new Error(`No Yjs node at path ${path.join('.')}`) } @@ -393,57 +419,69 @@ export const getYjsNode = ( return current } +export const getYjsNodeIf = ( + root: Y.XmlElement, + path: Path +): YjsNode | null => { + try { + return getYjsNode(root, path) + } catch { + return null + } +} + export const setVirtualYjsMove = ( root: Y.XmlElement, - target: Y.XmlElement | Y.XmlText, + target: YjsNode, wrapper: Y.XmlElement -) => { +): void => { const nodeId = ensureYjsNodeId(target) - target.setAttribute(HIDDEN_ATTRIBUTE, true) - wrapper.setAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) + hideYjsNode(target) + setYjsAttribute(wrapper, VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) } export const createVirtualYjsMovePlaceholder = ( - target: Y.XmlElement | Y.XmlText -) => { + target: YjsNode +): Y.XmlElement => { const nodeId = ensureYjsNodeId(target) const placeholder = new Y.XmlElement('slate-yjs-virtual-placeholder') - target.setAttribute(HIDDEN_ATTRIBUTE, true) - placeholder.setAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) - placeholder.setAttribute(VIRTUAL_PLACEHOLDER_ATTRIBUTE, true as never) + hideYjsNode(target) + setYjsAttribute(placeholder, VIRTUAL_CHILD_ID_ATTRIBUTE, nodeId) + setYjsAttribute(placeholder, VIRTUAL_PLACEHOLDER_ATTRIBUTE, true) return placeholder } -export const hideYjsNode = (node: Y.XmlElement | Y.XmlText) => { - node.setAttribute(HIDDEN_ATTRIBUTE, true as never) +export const hideYjsNode = (node: YjsNode): void => { + setYjsAttribute(node, HIDDEN_ATTRIBUTE, true) } export const insertYjsChild = ( root: Y.XmlElement, parent: Y.XmlElement, index: number, - child: Y.XmlElement | Y.XmlText -) => { + child: YjsNode +): void => { const rawChildren = getRawYjsChildren(parent) const visibleSlots = getYjsVisibleChildSlots(root, parent) + const visibleSlot = visibleSlots[index] const rawIndex = - index >= visibleSlots.length + index >= visibleSlots.length || !visibleSlot ? rawChildren.length - : visibleSlots[index]!.rawIndex + : visibleSlot.rawIndex parent.insert(rawIndex, [child]) } export const setVirtualYjsUnwrapMove = ( root: Y.XmlElement, - target: Y.XmlElement | Y.XmlText, + target: YjsNode, wrapper: Y.XmlElement, wrapperParent: Y.XmlElement, wrapperIndex: number -) => { +): void => { const nodeId = target.getAttribute(NODE_ID_ATTRIBUTE) if ( @@ -453,11 +491,11 @@ export const setVirtualYjsUnwrapMove = ( throw new Error('move_node unwrap target is not a virtual wrapper child.') } - removeAttribute(target, HIDDEN_ATTRIBUTE) - removeAttribute(wrapper, VIRTUAL_CHILD_ID_ATTRIBUTE) + target.removeAttribute(HIDDEN_ATTRIBUTE) + wrapper.removeAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) if (getRawYjsChildren(wrapper).length === 0) { - wrapper.setAttribute(HIDDEN_ATTRIBUTE, true as never) + hideYjsNode(wrapper) } else { insertYjsChild( root, @@ -469,9 +507,9 @@ export const setVirtualYjsUnwrapMove = ( } export const isVirtualYjsChild = ( - target: Y.XmlElement | Y.XmlText, + target: YjsNode, wrapper: Y.XmlElement -) => { +): boolean => { const nodeId = target.getAttribute(NODE_ID_ATTRIBUTE) return ( @@ -484,11 +522,15 @@ export const removeYjsVirtualPlaceholderChild = ( root: Y.XmlElement, parent: Y.XmlElement, index: number, - target: Y.XmlElement | Y.XmlText -) => { + target: YjsNode +): boolean => { const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] - if (!visibleSlot || visibleSlot.rawIndex < 0 || visibleSlot.node !== target) { + if ( + !visibleSlot || + !hasRawYjsChildSlot(visibleSlot) || + visibleSlot.node !== target + ) { return false } @@ -511,20 +553,20 @@ export const removeYjsChild = ( parent: Y.XmlElement, index: number, slateNode?: Descendant -): 'hidden' | 'hidden-parent' | 'visible' => { +): YjsChildRemovalMode => { const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] const rawChildren = getRawYjsChildren(parent) const hiddenIndex = rawChildren.findIndex( (child) => isHiddenYjsNode(child) && matchesSlateNode(child, slateNode) ) - if (visibleSlot) { - if (visibleSlot.rawIndex === -1) { + if (visibleSlot !== undefined) { + if (!hasRawYjsChildSlot(visibleSlot)) { throw new Error('Cannot remove a virtual Yjs child from its parent.') } if ( - slateNode && + slateNode !== undefined && !matchesSlateNode(visibleSlot.node, slateNode) && hiddenIndex !== -1 ) { @@ -537,7 +579,7 @@ export const removeYjsChild = ( visibleSlot.node instanceof Y.XmlElement && hasHiddenYjsDescendant(visibleSlot.node) ) { - visibleSlot.node.setAttribute(HIDDEN_ATTRIBUTE, true as never) + hideYjsNode(visibleSlot.node) return 'hidden-parent' } @@ -559,7 +601,7 @@ export const removeYjsChild = ( export const getYjsParent = ( root: Y.XmlElement, path: Path -): { index: number; parent: Y.XmlElement } => { +): { readonly index: number; readonly parent: Y.XmlElement } => { const index = path.at(-1) if (index === undefined) { @@ -567,7 +609,7 @@ export const getYjsParent = ( } const parentPath = path.slice(0, -1) - const parent = parentPath.length === 0 ? root : getYjsNode(root, parentPath) + const parent = getYjsNode(root, parentPath) if (parent instanceof Y.XmlText) { throw new Error(`Yjs parent is text at path ${parentPath.join('.')}`) @@ -576,15 +618,13 @@ export const getYjsParent = ( return { index, parent } } -const deleteInternalAttributes = (attributes: Record) => { - delete attributes[HIDDEN_ATTRIBUTE] - delete attributes[NODE_ID_ATTRIBUTE] - delete attributes[SPLIT_UNDO_TEXT_ATTRIBUTE] - delete attributes[VIRTUAL_CHILD_ID_ATTRIBUTE] - delete attributes[VIRTUAL_PLACEHOLDER_ATTRIBUTE] +const deleteInternalAttributes = (attributes: YjsAttributeRecord): void => { + for (const attribute of INTERNAL_YJS_ATTRIBUTES) { + delete attributes[attribute] + } } -const ensureYjsNodeId = (node: Y.XmlElement | Y.XmlText) => { +const ensureYjsNodeId = (node: YjsNode): string => { const currentId = node.getAttribute(NODE_ID_ATTRIBUTE) if (typeof currentId === 'string') { @@ -594,16 +634,16 @@ const ensureYjsNodeId = (node: Y.XmlElement | Y.XmlText) => { const scope = node.doc ? String(node.doc.clientID) : nodeIdScope const nextId = `slate-yjs-${scope}-${++nextNodeId}` - node.setAttribute(NODE_ID_ATTRIBUTE, nextId) + setYjsAttribute(node, NODE_ID_ATTRIBUTE, nextId) return nextId } const matchesSlateNode = ( - yjsNode: Y.XmlElement | Y.XmlText, + yjsNode: YjsNode, slateNode?: Descendant -) => { - if (!slateNode) { +): boolean => { + if (slateNode === undefined) { return false } @@ -615,18 +655,13 @@ const matchesSlateNode = ( return false } - return ( - (yjsNode.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? yjsNode.nodeName) === - String(slateNode.type ?? 'element') - ) + return getSlateYjsElementType(yjsNode) === String(slateNode.type ?? 'element') } -const hasHiddenYjsDescendant = (node: Y.XmlElement) => { +const hasHiddenYjsDescendant = (node: Y.XmlElement): boolean => { const stack = getRawYjsChildren(node) - while (stack.length > 0) { - const child = stack.pop()! - + for (let child = stack.pop(); child; child = stack.pop()) { if (isHiddenYjsNode(child)) { return true } @@ -639,15 +674,10 @@ const hasHiddenYjsDescendant = (node: Y.XmlElement) => { return false } -const findYjsNodeById = ( - root: Y.XmlElement, - id: string -): Y.XmlElement | Y.XmlText | null => { - const stack: Array = [root] - - while (stack.length > 0) { - const node = stack.pop()! +const findYjsNodeById = (root: Y.XmlElement, id: string): YjsNode | null => { + const stack: YjsNode[] = [root] + for (let node = stack.pop(); node; node = stack.pop()) { if (node.getAttribute(NODE_ID_ATTRIBUTE) === id) { return node } diff --git a/packages/slate-yjs/src/core/editor-adapter.ts b/packages/slate-yjs/src/core/editor-adapter.ts new file mode 100644 index 0000000000..f02311b3a3 --- /dev/null +++ b/packages/slate-yjs/src/core/editor-adapter.ts @@ -0,0 +1,114 @@ +import type { Descendant, Editor, Element, Operation, Range } from 'slate' +import { createEditor, NodeApi, OperationApi } from 'slate' +import { Editor as EditorApi } from 'slate/internal' + +export type YjsEditorAdapter = { + readonly importing: () => boolean + readonly readChildren: () => readonly Element[] + readonly readChildrenBeforeOperations: ( + operations: readonly Operation[] + ) => Element[] + readonly replaceValue: ( + children: readonly Descendant[], + selection: Range | null + ) => void +} + +const remoteImportOptions = { + metadata: { + collab: { origin: 'remote', saveToHistory: false }, + history: { mode: 'skip' }, + selection: { dom: 'preserve', focus: false, scroll: false }, + }, + tag: ['collaboration', 'remote-yjs-import'], +} as const + +const SELECTION_ROOT_TYPE = 'slate-yjs-selection-root' + +const rangePoints = ( + range: Range +): readonly [Range['anchor'], Range['focus']] => + [range.anchor, range.focus] as const + +const sanitizeImportSelection = ( + children: readonly Descendant[], + selection: Range | null +): Range | null => { + if (selection === null) { + return null + } + + const root: Element = { children: [...children], type: SELECTION_ROOT_TYPE } + + return rangePoints(selection).every((point) => + isValidImportSelectionPoint(root, point) + ) + ? selection + : null +} + +const isValidImportSelectionPoint = ( + root: Element, + point: Range['anchor'] +): boolean => { + const node = NodeApi.getIf(root, point.path) + + return ( + node !== undefined && + NodeApi.isText(node) && + point.offset >= 0 && + point.offset <= node.text.length + ) +} + +export const createYjsEditorAdapter = (editor: Editor): YjsEditorAdapter => { + let importing = false + + const readChildren = (): readonly Element[] => + editor.read((state) => [...state.value.get().roots.main]) + + const readChildrenBeforeOperations = ( + operations: readonly Operation[] + ): Element[] => { + const baselineEditor = createEditor() + + EditorApi.replace(baselineEditor, { + children: [...readChildren()], + marks: null, + selection: null, + }) + baselineEditor.update((tx) => { + tx.operations.replay([...operations].reverse().map(OperationApi.inverse)) + }) + + return EditorApi.getSnapshot(baselineEditor).children + } + + const replaceValue = ( + children: readonly Descendant[], + selection: Range | null + ): void => { + const nextSelection = sanitizeImportSelection(children, selection) + + importing = true + + try { + editor.update((tx) => { + tx.value.replace({ + children: [...children], + marks: null, + selection: nextSelection, + }) + }, remoteImportOptions) + } finally { + importing = false + } + } + + return { + importing: () => importing, + readChildren, + readChildrenBeforeOperations, + replaceValue, + } +} diff --git a/packages/slate-yjs/src/core/editor-yjs.ts b/packages/slate-yjs/src/core/editor-yjs.ts new file mode 100644 index 0000000000..ab63bf1cf5 --- /dev/null +++ b/packages/slate-yjs/src/core/editor-yjs.ts @@ -0,0 +1,17 @@ +import type { EditorCoreStateView, EditorCoreUpdateTransaction } from 'slate' + +import type { YjsState, YjsTx } from './types' + +type EditorYjsStateView = EditorCoreStateView & { + yjs: YjsState +} + +type EditorYjsUpdateTransaction = EditorCoreUpdateTransaction & { + yjs: YjsTx +} + +export const getEditorYjsState = (state: EditorCoreStateView): YjsState => + (state as EditorYjsStateView).yjs + +export const getEditorYjsTx = (tx: EditorCoreUpdateTransaction): YjsTx => + (tx as EditorYjsUpdateTransaction).yjs diff --git a/packages/slate-yjs/src/core/extension.ts b/packages/slate-yjs/src/core/extension.ts index fd53543ef7..abd7138b83 100644 --- a/packages/slate-yjs/src/core/extension.ts +++ b/packages/slate-yjs/src/core/extension.ts @@ -3,7 +3,9 @@ import { defineEditorExtension } from 'slate' import { YjsController } from './controller' import type { YjsExtensionOptions } from './types' -export const createYjsExtension = (options: YjsExtensionOptions = {}) => +export const createYjsExtension = ( + options: YjsExtensionOptions = {} +): ReturnType => defineEditorExtension({ name: 'yjs', setup(context) { @@ -12,10 +14,10 @@ export const createYjsExtension = (options: YjsExtensionOptions = {}) => controller.seed() return { - cleanup() { + cleanup(): void { controller.destroy() }, - onCommit({ commit, snapshot }) { + onCommit({ commit, snapshot }): void { controller.handleCommit(commit, snapshot) }, state: { diff --git a/packages/slate-yjs/src/core/history.ts b/packages/slate-yjs/src/core/history.ts index a3a3666478..57852cc741 100644 --- a/packages/slate-yjs/src/core/history.ts +++ b/packages/slate-yjs/src/core/history.ts @@ -1,8 +1,10 @@ import type { Editor, Operation } from 'slate' +import { isRecord } from './record' + type HistoryBatchLike = { operations?: Operation[] - statePatches?: unknown[] + statePatches?: readonly unknown[] } type HistoryLike = { @@ -17,14 +19,49 @@ type HistoryStateView = { } } -const operationsEqual = (a: Operation, b: Operation | undefined) => - !!b && JSON.stringify(a) === JSON.stringify(b) +const isHistoryState = (value: unknown): value is HistoryStateView => + isRecord(value) && + (value.history === undefined || + (isRecord(value.history) && + (value.history.redos === undefined || + typeof value.history.redos === 'function') && + (value.history.undos === undefined || + typeof value.history.undos === 'function'))) + +const operationSignature = (operation: Operation): string => + JSON.stringify(operation) + +const operationMatchesSignature = ( + signature: string, + operation: Operation | undefined +): boolean => + operation !== undefined && signature === operationSignature(operation) + +const isEmptyHistoryBatch = (batch: HistoryBatchLike): boolean => + batch.operations?.length === 0 && (batch.statePatches?.length ?? 0) === 0 + +const getHistoryBatchOperationSuffixStart = ( + batchOperations: readonly Operation[], + operationSignatures: readonly string[] +): number | null => { + if (batchOperations.length < operationSignatures.length) { + return null + } + + const start = batchOperations.length - operationSignatures.length + + const matches = operationSignatures.every((signature, index) => + operationMatchesSignature(signature, batchOperations[start + index]) + ) + + return matches ? start : null +} const readEditorHistory = (editor: Editor): HistoryLike | null => editor.read((state) => { - const history = (state as HistoryStateView).history + const history = isHistoryState(state) ? state.history : undefined - if (!history) { + if (history === undefined) { return null } @@ -37,11 +74,13 @@ const readEditorHistory = (editor: Editor): HistoryLike | null => const removeOperationsFromHistoryStack = ( stack: HistoryBatchLike[] | undefined, operations: readonly Operation[] -) => { - if (!stack || operations.length === 0) { +): void => { + if (stack === undefined || operations.length === 0) { return } + const operationSignatures = operations.map(operationSignature) + for (let batchIndex = stack.length - 1; batchIndex >= 0; batchIndex -= 1) { const batch = stack[batchIndex] const batchOperations = batch?.operations @@ -50,23 +89,15 @@ const removeOperationsFromHistoryStack = ( throw new Error('Cannot remove rejected Yjs operations from history.') } - if (batchOperations.length < operations.length) { - continue - } - - const start = batchOperations.length - operations.length + const start = getHistoryBatchOperationSuffixStart( + batchOperations, + operationSignatures + ) - if ( - operations.every((operation, index) => - operationsEqual(operation, batchOperations[start + index]) - ) - ) { + if (start !== null) { batchOperations.splice(start, operations.length) - if ( - batchOperations.length === 0 && - (batch.statePatches?.length ?? 0) === 0 - ) { + if (isEmptyHistoryBatch(batch)) { stack.splice(batchIndex, 1) } @@ -78,10 +109,10 @@ const removeOperationsFromHistoryStack = ( export const removeRejectedYjsOperationsFromHistory = ( editor: Editor, operations: readonly Operation[] -) => { +): void => { const history = readEditorHistory(editor) - if (!history) { + if (history === null) { return } @@ -92,8 +123,8 @@ export const removeRejectedYjsOperationsFromHistory = ( export const removeRejectedYjsOperationsFromHistoryAfterCommit = ( editor: Editor, operations: readonly Operation[] -) => { - const remove = () => { +): void => { + const remove = (): void => { removeRejectedYjsOperationsFromHistory(editor, operations) } diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index a0cb9c9747..b78b444ef5 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -1,13 +1,22 @@ -import type { Descendant, Operation } from 'slate' +import type { Operation, Path } from 'slate' import * as Y from 'yjs' import { - cloneVisibleYjsNode, + getSlateYjsElementType, + hasYjsAttributes, + toYjsAttributeRecord, + type YjsNode, +} from './attributes' +import { + cloneVisibleYjsNodes, createVirtualYjsMovePlaceholder, createYjsNode, + createYjsNodes, + createYjsText, getYjsChildren, getYjsLength, getYjsNode, + getYjsNodeIf, getYjsParent, getYjsTextContent, getYjsVisibleChildren, @@ -16,331 +25,25 @@ import { isVirtualYjsChild, removeYjsChild, removeYjsVirtualPlaceholderChild, + replaceYjsChildren, setVirtualYjsMove, setVirtualYjsUnwrapMove, } from './document' -import type { YjsTraceEntry } from './types' - -const SLATE_TYPE_ATTRIBUTE = 'slate:type' - -type SlateElementLike = { - children: readonly Descendant[] -} & Record - -const areJsonEqual = (left: unknown, right: unknown) => - JSON.stringify(left) === JSON.stringify(right) - -export const isNoopSlateOperationForYjs = (operation: Operation) => { - switch (operation.type) { - case 'replace_children': - case 'replace_fragment': - return areJsonEqual(operation.children, operation.newChildren) - default: - return false - } -} - -const isSlateText = ( - node: unknown -): node is { text: string } & Record => - typeof node === 'object' && - node !== null && - 'text' in node && - typeof (node as { text?: unknown }).text === 'string' - -const isSlateElement = (node: unknown): node is SlateElementLike => - typeof node === 'object' && - node !== null && - 'children' in node && - Array.isArray((node as { children?: unknown }).children) - -const getTextAttributes = ({ text: _text, ...attributes }: { text: string }) => - attributes as Record - -const getElementAttributes = ({ - children: _children, - ...attributes -}: SlateElementLike) => attributes - -const createYjsText = (text: string, attributes: Record) => { - const yjsText = new Y.XmlText() - - for (const [key, value] of Object.entries(attributes)) { - yjsText.setAttribute(key, value as never) - } - - if (text.length > 0) { - yjsText.insert(0, text, attributes) - } - - return yjsText -} - -const setElementAttributes = ( - element: Y.XmlElement, - attributes: Record -) => { - for (const [key, value] of Object.entries(attributes)) { - if (key === 'type') { - element.setAttribute(SLATE_TYPE_ATTRIBUTE, String(value)) - continue - } - - element.setAttribute(key, value as never) - } -} - -const setYjsAttribute = ( - node: Y.XmlElement | Y.XmlText, - key: string, - value: unknown -) => { - if (key === 'type' && node instanceof Y.XmlElement) { - node.setAttribute(SLATE_TYPE_ATTRIBUTE, String(value)) - return - } - - node.setAttribute(key, value as never) -} - -const removeYjsAttribute = (node: Y.XmlElement | Y.XmlText, key: string) => { - if (key === 'type' && node instanceof Y.XmlElement) { - node.removeAttribute(SLATE_TYPE_ATTRIBUTE) - return - } - - node.removeAttribute(key) -} - -const applyTextFormatPatch = ( - text: Y.XmlText, - patch: Record -) => { - const length = getYjsLength(text) - - if (length === 0) { - return - } - - text.format(0, length, patch as Record) -} - -const setYjsNodeAttributes = ( - node: Y.XmlElement | Y.XmlText, - properties: Record, - newProperties: Record -) => { - const textPatch: Record = {} - - for (const [key, value] of Object.entries(newProperties)) { - if (key === 'children' || key === 'text') { - throw new Error(`Cannot set the "${key}" property on a Yjs node.`) - } - - if (value == null) { - removeYjsAttribute(node, key) - textPatch[key] = null - continue - } - - setYjsAttribute(node, key, value) - - if (node instanceof Y.XmlText) { - textPatch[key] = value - } - } - - for (const key of Object.keys(properties)) { - if (Object.hasOwn(newProperties, key)) { - continue - } - if (key === 'children' || key === 'text') { - throw new Error(`Cannot set the "${key}" property on a Yjs node.`) - } - - removeYjsAttribute(node, key) - - if (node instanceof Y.XmlText) { - textPatch[key] = null - } - } - - if (node instanceof Y.XmlText && Object.keys(textPatch).length > 0) { - applyTextFormatPatch(node, textPatch) - } -} - -const createSplitElement = ( - original: Y.XmlElement, - properties: Record, - children: Array -) => { - const type = - typeof properties.type === 'string' - ? properties.type - : (original.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? original.nodeName) - const element = new Y.XmlElement(String(type)) - - element.setAttribute(SLATE_TYPE_ATTRIBUTE, String(type)) - setElementAttributes(element, properties) - - if (children.length > 0) { - element.insert(0, children) - } - - return element -} - -const getSharedPrefixLength = (left: string, right: string) => { - let index = 0 - - while ( - index < left.length && - index < right.length && - left[index] === right[index] - ) { - index++ - } - - return index -} - -const getSharedSuffixLength = ( - left: string, - right: string, - prefixLength: number -) => { - let length = 0 - - while ( - length < left.length - prefixLength && - length < right.length - prefixLength && - left.at(-1 - length) === right.at(-1 - length) - ) { - length++ - } - - return length -} - -const replaceYjsText = ( - text: Y.XmlText, - previous: string, - next: string, - attributes: Record -) => { - const prefixLength = getSharedPrefixLength(previous, next) - const suffixLength = getSharedSuffixLength(previous, next, prefixLength) - const removeLength = previous.length - prefixLength - suffixLength - const insertText = next.slice(prefixLength, next.length - suffixLength) - - if (removeLength > 0) { - text.delete(prefixLength, removeLength) - } - - if (insertText.length > 0) { - text.insert(prefixLength, insertText, attributes) - } -} - -const canReplaceCompatibleYjsChildren = ( - children: Array, - oldChildren: readonly Descendant[], - newChildren: readonly Descendant[] -): boolean => { - if ( - children.length !== oldChildren.length || - children.length !== newChildren.length - ) { - return false - } - - return children.every((child, index) => { - const oldChild = oldChildren[index] - const newChild = newChildren[index] - - if (child instanceof Y.XmlText) { - return isSlateText(oldChild) && isSlateText(newChild) - } - - if ( - child instanceof Y.XmlElement && - isSlateElement(oldChild) && - isSlateElement(newChild) - ) { - return canReplaceCompatibleYjsChildren( - getYjsChildren(child), - oldChild.children, - newChild.children - ) - } - - return false - }) -} - -const replaceCompatibleYjsChildren = ( - children: Array, - oldChildren: readonly Descendant[], - newChildren: readonly Descendant[] -): boolean => { - if (!canReplaceCompatibleYjsChildren(children, oldChildren, newChildren)) { - return false - } - - children.forEach((child, index) => { - const oldChild = oldChildren[index]! - const newChild = newChildren[index]! - - if (child instanceof Y.XmlText) { - if (!isSlateText(oldChild) || !isSlateText(newChild)) { - return - } - - const attributes = getTextAttributes(newChild) - - setYjsNodeAttributes(child, getTextAttributes(oldChild), attributes) - replaceYjsText(child, oldChild.text, newChild.text, attributes) - - return - } - - if ( - child instanceof Y.XmlElement && - isSlateElement(oldChild) && - isSlateElement(newChild) - ) { - setYjsNodeAttributes( - child, - getElementAttributes(oldChild), - getElementAttributes(newChild) - ) - replaceCompatibleYjsChildren( - getYjsChildren(child), - oldChild.children, - newChild.children - ) - } - }) - - return true -} +import { pathsEqual } from './path' +import { isRecord } from './record' +import { + createSplitElement, + isNoopSlateOperationForYjs as isNoopOperationForYjs, + replaceCompatibleYjsChildren, + setYjsNodeAttributes, +} from './replacement' +import type { YjsTraceEntry, YjsTraceFallback } from './types' -const pathsEqual = (left: readonly number[], right: readonly number[]) => - left.length === right.length && - left.every((part, index) => part === right[index]) - -const getYjsNodeIf = (root: Y.XmlElement, path: number[]) => { - try { - return getYjsNode(root, path) - } catch { - return null - } -} +export { isNoopSlateOperationForYjs } from './replacement' const materializeEmptyYjsText = ( root: Y.XmlElement, - path: number[] + path: Path ): Y.XmlText | null => { const index = path.at(-1) @@ -349,7 +52,7 @@ const materializeEmptyYjsText = ( } const parentPath = path.slice(0, -1) - const parent = parentPath.length === 0 ? root : getYjsNodeIf(root, parentPath) + const parent = getYjsNodeIf(root, parentPath) if (!(parent instanceof Y.XmlElement)) { return null @@ -365,28 +68,37 @@ const materializeEmptyYjsText = ( return text } -const getYjsTextForInsert = (root: Y.XmlElement, path: number[]) => { +const getYjsTextForInsert = ( + root: Y.XmlElement, + path: Path +): Y.XmlText | null => { const target = getYjsNodeIf(root, path) if (target instanceof Y.XmlText) { return target } - if (target) { - return target + if (target !== null) { + return null } return materializeEmptyYjsText(root, path) } +type YjsTextPoint = { + readonly childIndex: number + readonly offset: number + readonly parent: Y.XmlElement +} + const resolveYjsTextPoint = ( root: Y.XmlElement, - path: number[], + path: Path, offset: number -) => { +): YjsTextPoint | null => { const target = getYjsNode(root, path) if (!(target instanceof Y.XmlText)) { - return target + throw new Error('remove_text target is not a Y.XmlText.') } const { index, parent } = getYjsParent(root, path) @@ -403,7 +115,7 @@ const resolveYjsTextPoint = ( const length = getYjsLength(child) if (remainingOffset <= length) { - return { childIndex, offset: remainingOffset, parent, text: child } + return { childIndex, offset: remainingOffset, parent } } remainingOffset -= length @@ -414,20 +126,16 @@ const resolveYjsTextPoint = ( const deleteYjsTextRange = ( root: Y.XmlElement, - path: number[], + path: Path, offset: number, length: number -) => { +): void => { const point = resolveYjsTextPoint(root, path, offset) - if (!point) { + if (point === null) { return } - if (point instanceof Y.XmlText || point instanceof Y.XmlElement) { - throw new Error('remove_text target is not a Y.XmlText.') - } - let childIndex = point.childIndex let deleteOffset = point.offset let remainingLength = length @@ -463,23 +171,16 @@ const deleteYjsTextRange = ( } } -const isEmptyYjsText = (node: Y.XmlElement | Y.XmlText) => +const isEmptyYjsText = (node: YjsNode): boolean => node instanceof Y.XmlText && getYjsTextContent(node).length === 0 -const hasYjsAttributes = (node: Y.XmlElement | Y.XmlText) => - Object.keys( - ( - node as unknown as { getAttributes(): Record } - ).getAttributes() - ).length > 0 - const removeRedundantEmptyYjsText = ( root: Y.XmlElement, parent: Y.XmlElement, index: number, text: Y.XmlText -) => { - if (getYjsTextContent(text).length > 0 || hasYjsAttributes(text)) { +): boolean => { + if (!isEmptyYjsText(text) || hasYjsAttributes(text)) { return false } if (getYjsVisibleChildren(root, parent).length <= 1) { @@ -491,9 +192,6 @@ const removeRedundantEmptyYjsText = ( return true } -const getYjsElementType = (element: Y.XmlElement) => - String(element.getAttribute(SLATE_TYPE_ATTRIBUTE) ?? element.nodeName) - type YjsElementChildKind = 'element' | 'empty' | 'mixed' | 'text' const getYjsElementChildKind = ( @@ -522,8 +220,8 @@ const canMergeYjsElements = ( root: Y.XmlElement, previous: Y.XmlElement, target: Y.XmlElement -) => { - if (getYjsElementType(previous) !== getYjsElementType(target)) { +): boolean => { + if (getSlateYjsElementType(previous) !== getSlateYjsElementType(target)) { return false } @@ -541,21 +239,51 @@ const canMergeYjsElements = ( ) } -const unsupportedYjsOperation = (operation: never): never => { - const operationType = (operation as { type?: unknown }).type +const getUnsupportedOperationType = (operation: unknown): string => { + const operationType = isRecord(operation) ? operation.type : undefined + + return typeof operationType === 'string' ? operationType : 'unknown' +} +const unsupportedYjsOperation = (operation: never): never => { throw new Error( - `Unsupported Yjs operation: ${ - typeof operationType === 'string' ? operationType : 'unknown' - }` + `Unsupported Yjs operation: ${getUnsupportedOperationType(operation)}` ) } +const operationTrace = (operation: Operation): YjsTraceEntry => ({ + mode: 'operation', + operationType: operation.type, +}) + +const traceableFallback = ( + operation: Operation, + fallback: YjsTraceFallback +): YjsTraceEntry => ({ + fallback, + mode: 'traceable-fallback', + operationType: operation.type, +}) + +const getYjsElementOperationTarget = ( + root: Y.XmlElement, + path: Path, + operationType: string +): Y.XmlElement => { + const target = getYjsNode(root, path) + + if (!(target instanceof Y.XmlElement)) { + throw new Error(`${operationType} target is not a Y.XmlElement.`) + } + + return target +} + export const applySlateOperationToYjs = ( root: Y.XmlElement, operation: Operation ): YjsTraceEntry | null => { - if (isNoopSlateOperationForYjs(operation)) { + if (isNoopOperationForYjs(operation)) { return null } @@ -569,7 +297,7 @@ export const applySlateOperationToYjs = ( text.insert(operation.offset, operation.text) - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'remove_text': { deleteYjsTextRange( @@ -579,35 +307,27 @@ export const applySlateOperationToYjs = ( operation.text.length ) - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'insert_node': { const { index, parent } = getYjsParent(root, operation.path) insertYjsChild(root, parent, index, createYjsNode(operation.node)) - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'remove_node': { const { index, parent } = getYjsParent(root, operation.path) const removalMode = removeYjsChild(root, parent, index, operation.node) if (removalMode === 'hidden') { - return { - fallback: 'virtual-unwrap-wrapper-remove', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'virtual-unwrap-wrapper-remove') } if (removalMode === 'hidden-parent') { - return { - fallback: 'virtual-move-parent-remove', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'virtual-move-parent-remove') } - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'split_node': { const target = getYjsNode(root, operation.path) @@ -624,23 +344,17 @@ export const applySlateOperationToYjs = ( root, parent, index + 1, - createYjsText( - rightText, - operation.properties as Record - ) + createYjsText(rightText, toYjsAttributeRecord(operation.properties)) ) - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } const children = getYjsChildren(target) - const rightChildren = children - .slice(operation.position) - .flatMap((child) => { - const clone = cloneVisibleYjsNode(root, child) - - return clone ? [clone] : [] - }) + const rightChildren = cloneVisibleYjsNodes( + root, + children.slice(operation.position) + ) const deleteCount = getYjsLength(target) - operation.position if (deleteCount > 0) { @@ -653,12 +367,12 @@ export const applySlateOperationToYjs = ( index + 1, createSplitElement( target, - operation.properties as Record, + toYjsAttributeRecord(operation.properties), rightChildren ) ) - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'merge_node': { const { index, parent } = getYjsParent(root, operation.path) @@ -672,11 +386,7 @@ export const applySlateOperationToYjs = ( const target = children[index] if (previous instanceof Y.XmlText && !target) { - return { - fallback: 'empty-text-merge-elided', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'empty-text-merge-elided') } if (!previous || !target) { @@ -684,27 +394,22 @@ export const applySlateOperationToYjs = ( } if (previous instanceof Y.XmlText && target instanceof Y.XmlText) { - return { - fallback: 'text-merge-preserve-yjs-boundary', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'text-merge-preserve-yjs-boundary') } if (previous instanceof Y.XmlElement && target instanceof Y.XmlElement) { if (!canMergeYjsElements(root, previous, target)) { - return { - fallback: 'incompatible-structural-merge-elided', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback( + operation, + 'incompatible-structural-merge-elided' + ) } - const previousHasChildren = + const previousHasVisibleChildren = getYjsVisibleChildren(root, previous).length > 0 for (const moveTarget of getYjsVisibleChildren(root, target)) { - if (previousHasChildren && isEmptyYjsText(moveTarget)) { + if (previousHasVisibleChildren && isEmptyYjsText(moveTarget)) { continue } @@ -719,22 +424,17 @@ export const applySlateOperationToYjs = ( removeYjsVirtualPlaceholderChild(root, parent, index, target) hideYjsNode(target) - return { - fallback: 'virtual-merge-ref', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'virtual-merge-ref') } throw new Error('Cannot merge Yjs nodes of different kinds.') } case 'replace_fragment': { - const target = - operation.path.length === 0 ? root : getYjsNode(root, operation.path) - - if (!(target instanceof Y.XmlElement)) { - throw new Error('replace_fragment target is not a Y.XmlElement.') - } + const target = getYjsElementOperationTarget( + root, + operation.path, + operation.type + ) const children = getYjsChildren(target) if ( @@ -744,22 +444,15 @@ export const applySlateOperationToYjs = ( operation.newChildren ) ) { - return { mode: 'operation', operationType: operation.type } - } - - if (getYjsLength(target) > 0) { - target.delete(0, getYjsLength(target)) + return operationTrace(operation) } - if (operation.newChildren.length > 0) { - target.insert(0, operation.newChildren.map(createYjsNode)) - } + replaceYjsChildren(target, operation.newChildren) - return { - fallback: 'replace-fragment-scoped-replace-identity-risk', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback( + operation, + 'replace-fragment-scoped-replace-identity-risk' + ) } case 'set_selection': return null @@ -768,19 +461,18 @@ export const applySlateOperationToYjs = ( setYjsNodeAttributes( node, - operation.properties as Record, - operation.newProperties as Record + toYjsAttributeRecord(operation.properties), + toYjsAttributeRecord(operation.newProperties) ) - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'replace_children': { - const target = - operation.path.length === 0 ? root : getYjsNode(root, operation.path) - - if (!(target instanceof Y.XmlElement)) { - throw new Error('replace_children target is not a Y.XmlElement.') - } + const target = getYjsElementOperationTarget( + root, + operation.path, + operation.type + ) const existingChildren = getYjsVisibleChildren(root, target).slice( operation.index, @@ -794,53 +486,38 @@ export const applySlateOperationToYjs = ( operation.newChildren ) ) { - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } const removalModes = operation.children.map((child) => removeYjsChild(root, target, operation.index, child) ) - operation.newChildren.forEach((child, offset) => { - insertYjsChild( - root, - target, - operation.index + offset, - createYjsNode(child) - ) + const newChildren = createYjsNodes(operation.newChildren) + + newChildren.forEach((child, offset) => { + insertYjsChild(root, target, operation.index + offset, child) }) if (removalModes.some((mode) => mode !== 'visible')) { - return { - fallback: 'replace-children-virtual-removal', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'replace-children-virtual-removal') } - return { mode: 'operation', operationType: operation.type } + return operationTrace(operation) } case 'move_node': { const target = getYjsNodeIf(root, operation.path) const sourceIndex = operation.path.at(-1) - if (!target) { - return { - fallback: 'missing-move-source-elided', - mode: 'traceable-fallback', - operationType: operation.type, - } + if (target === null) { + return traceableFallback(operation, 'missing-move-source-elided') } const sourceParentPath = operation.path.slice(0, -1) - const sourceParent = - sourceParentPath.length === 0 - ? root - : getYjsNodeIf(root, sourceParentPath) + const sourceParent = getYjsNodeIf(root, sourceParentPath) const newParentPath = operation.newPath.slice(0, -1) const newIndex = operation.newPath.at(-1) - const newParent = - newParentPath.length === 0 ? root : getYjsNodeIf(root, newParentPath) + const newParent = getYjsNodeIf(root, newParentPath) if ( sourceParent instanceof Y.XmlElement && @@ -860,24 +537,31 @@ export const applySlateOperationToYjs = ( wrapperIndex ) - return { - fallback: 'virtual-unwrap-ref', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'virtual-unwrap-ref') } if (!(newParent instanceof Y.XmlElement)) { - return { - fallback: 'missing-move-destination-elided', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'missing-move-destination-elided') } if (newIndex === undefined) { throw new Error('move_node destination is missing an index.') } + const removeSourceVirtualPlaceholder = (): void => { + if ( + sourceParent instanceof Y.XmlElement && + sourceParent !== newParent && + sourceIndex !== undefined + ) { + removeYjsVirtualPlaceholderChild( + root, + sourceParent, + sourceIndex, + target + ) + } + } + if ( sourceParent instanceof Y.XmlElement && sourceParent === newParent && @@ -890,63 +574,34 @@ export const applySlateOperationToYjs = ( target ) } - const insertionIndex = newIndex const newParentChildren = getYjsVisibleChildren(root, newParent) + const firstNewParentChild = newParentChildren[0] if ( - insertionIndex === 0 && + newIndex === 0 && newParentChildren.length === 1 && - isEmptyYjsText(newParentChildren[0]!) + firstNewParentChild && + isEmptyYjsText(firstNewParentChild) ) { removeYjsChild(root, newParent, 0) } - if (insertionIndex === 0 && getYjsLength(newParent) === 0) { + if (newIndex === 0 && getYjsLength(newParent) === 0) { setVirtualYjsMove(root, target, newParent) - if ( - sourceParent instanceof Y.XmlElement && - sourceParent !== newParent && - sourceIndex !== undefined - ) { - removeYjsVirtualPlaceholderChild( - root, - sourceParent, - sourceIndex, - target - ) - } + removeSourceVirtualPlaceholder() - return { - fallback: 'virtual-move-ref', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'virtual-move-ref') } insertYjsChild( root, newParent, - insertionIndex, + newIndex, createVirtualYjsMovePlaceholder(target) ) - if ( - sourceParent instanceof Y.XmlElement && - sourceParent !== newParent && - sourceIndex !== undefined - ) { - removeYjsVirtualPlaceholderChild( - root, - sourceParent, - sourceIndex, - target - ) - } + removeSourceVirtualPlaceholder() - return { - fallback: 'virtual-move-placeholder', - mode: 'traceable-fallback', - operationType: operation.type, - } + return traceableFallback(operation, 'virtual-move-placeholder') } } diff --git a/packages/slate-yjs/src/core/path.ts b/packages/slate-yjs/src/core/path.ts new file mode 100644 index 0000000000..e7eaeb9420 --- /dev/null +++ b/packages/slate-yjs/src/core/path.ts @@ -0,0 +1,18 @@ +import type { Path } from 'slate' + +export const nextPath = (path: Path): Path => { + const index = path.at(-1) + + if (index === undefined) { + throw new Error('Cannot get a next path for the root.') + } + + return [...path.slice(0, -1), index + 1] +} + +export const pathsEqual = ( + left: readonly number[], + right: readonly number[] +): boolean => + left.length === right.length && + left.every((part, index) => part === right[index]) diff --git a/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts b/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts new file mode 100644 index 0000000000..15fffc5e5b --- /dev/null +++ b/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts @@ -0,0 +1,232 @@ +import { + connectedFromYjsProviderStatus, + isPromiseLike, + normalizeYjsProviderStatus, + normalizeYjsProviderSynced, + readYjsProviderStatus, + readYjsProviderSynced, +} from './provider' +import type { + YjsProviderEvent, + YjsProviderLike, + YjsProviderStatus, +} from './types' + +type YjsProviderLifecycleAdapterOptions = { + readonly onConnectedChange: () => void + readonly onProviderSyncedChange: () => void + readonly provider?: YjsProviderLike +} + +export type YjsProviderLifecycleAdapter = { + readonly bind: () => void + readonly connect: () => unknown + readonly connected: () => boolean + readonly disconnect: () => unknown + readonly providerRevision: () => number + readonly providerStatus: () => YjsProviderStatus | null + readonly providerSynced: () => boolean | null + readonly reconnect: () => void + readonly subscribe: (listener: () => void) => () => void + readonly unbind: () => void +} + +const PROVIDER_SYNC_EVENTS = [ + 'sync', + 'synced', +] as const satisfies readonly YjsProviderEvent[] + +const notifySubscribers = (subscribers: ReadonlySet<() => void>): void => { + for (const listener of subscribers) { + listener() + } +} + +const isStaleConnectedProviderStatus = ( + status: YjsProviderStatus, + fallbackConnected: boolean +): boolean => !fallbackConnected && status === 'connected' + +export const createYjsProviderLifecycleAdapter = ({ + onConnectedChange, + onProviderSyncedChange, + provider, +}: YjsProviderLifecycleAdapterOptions): YjsProviderLifecycleAdapter => { + const subscribers = new Set<() => void>() + let providerRevision = 0 + let providerStatusValue = readYjsProviderStatus(provider) + let providerSyncedValue = readYjsProviderSynced(provider) + let connected = connectedFromYjsProviderStatus(providerStatusValue, true) + + const updateProviderRevision = (): void => { + providerRevision += 1 + + notifySubscribers(subscribers) + } + + const setConnected = (nextConnected: boolean): void => { + if (connected === nextConnected) { + return + } + + connected = nextConnected + onConnectedChange() + } + + const updateConnectedFromProviderStatus = ( + status: YjsProviderStatus + ): void => { + setConnected(connectedFromYjsProviderStatus(status, connected)) + } + + const updateProviderStatus = (status: YjsProviderStatus): void => { + updateConnectedFromProviderStatus(status) + + if (providerStatusValue === status) { + return + } + + providerStatusValue = status + updateProviderRevision() + } + + const updateProviderSynced = (synced: boolean): void => { + if (providerSyncedValue === synced) { + return + } + + providerSyncedValue = synced + onProviderSyncedChange() + updateProviderRevision() + } + + const providerStatusObserver = (payload: unknown): void => { + const status = normalizeYjsProviderStatus(payload) + + if (status !== null) { + updateProviderStatus(status) + } + } + + const providerSyncedObserver = (payload: unknown): void => { + const synced = + normalizeYjsProviderSynced(payload) ?? readYjsProviderSynced(provider) + + if (synced !== null) { + updateProviderSynced(synced) + } + } + + const syncProviderLifecycleStatus = (fallbackConnected: boolean): void => { + const status = readYjsProviderStatus(provider) + + if (status !== null) { + if (isStaleConnectedProviderStatus(status, fallbackConnected)) { + return + } + + updateProviderStatus(status) + + return + } + + if (providerStatusValue === null) { + setConnected(fallbackConnected) + } + } + + const syncProviderLifecycleResult = ( + result: Promise | unknown, + fallbackConnected: boolean + ): void => { + if (isPromiseLike(result)) { + void result.then( + () => { + syncProviderLifecycleStatus(fallbackConnected) + }, + () => undefined + ) + + return + } + + syncProviderLifecycleStatus(fallbackConnected) + } + + const bind = (): void => { + provider?.on?.('status', providerStatusObserver) + for (const event of PROVIDER_SYNC_EVENTS) { + provider?.on?.(event, providerSyncedObserver) + } + } + + const unbind = (): void => { + provider?.off?.('status', providerStatusObserver) + for (const event of PROVIDER_SYNC_EVENTS) { + provider?.off?.(event, providerSyncedObserver) + } + } + + const connect = (): unknown => { + if (provider !== undefined) { + const result = provider.connect?.() + + syncProviderLifecycleResult(result, true) + + return result + } + + setConnected(true) + } + + const disconnect = (): unknown => { + if (provider !== undefined) { + setConnected(false) + const result = provider.disconnect?.() + + syncProviderLifecycleResult(result, false) + + return result + } + + setConnected(false) + } + + const reconnect = (): void => { + const result = disconnect() + + if (isPromiseLike(result)) { + void result.then( + () => { + connect() + }, + () => undefined + ) + + return + } + + connect() + } + + const subscribe = (listener: () => void): (() => void) => { + subscribers.add(listener) + + return () => { + subscribers.delete(listener) + } + } + + return { + bind, + connect, + connected: () => connected, + disconnect, + providerRevision: () => providerRevision, + providerStatus: () => providerStatusValue, + providerSynced: () => providerSyncedValue, + reconnect, + subscribe, + unbind, + } +} diff --git a/packages/slate-yjs/src/core/provider.ts b/packages/slate-yjs/src/core/provider.ts index e9945e6807..61c024b5ca 100644 --- a/packages/slate-yjs/src/core/provider.ts +++ b/packages/slate-yjs/src/core/provider.ts @@ -1,71 +1,61 @@ -import type { - YjsProviderLike, - YjsProviderStatus, - YjsProviderStatusPayload, - YjsProviderSyncedPayload, -} from './types' +import { isRecord } from './record' +import type { YjsProviderLike, YjsProviderStatus } from './types' + +const isYjsProviderStatus = (value: unknown): value is YjsProviderStatus => + typeof value === 'string' + +const readBooleanProperty = ( + record: Readonly>, + key: string +): boolean | null => { + const value = record[key] + + return typeof value === 'boolean' ? value : null +} export const normalizeYjsProviderStatus = ( - value: YjsProviderStatusPayload | unknown + value: unknown ): YjsProviderStatus | null => { - if (typeof value === 'string') { + if (isYjsProviderStatus(value)) { return value } - if ( - value && - typeof value === 'object' && - 'status' in value && - typeof value.status === 'string' - ) { + if (isRecord(value) && isYjsProviderStatus(value.status)) { return value.status } return null } -export const normalizeYjsProviderSynced = ( - value: YjsProviderSyncedPayload | unknown -): boolean | null => { +export const normalizeYjsProviderSynced = (value: unknown): boolean | null => { if (typeof value === 'boolean') { return value } - if ( - value && - typeof value === 'object' && - 'state' in value && - typeof value.state === 'boolean' - ) { - return value.state + if (!isRecord(value)) { + return null } - if ( - value && - typeof value === 'object' && - 'synced' in value && - typeof value.synced === 'boolean' - ) { - return value.synced - } - - return null + return ( + readBooleanProperty(value, 'state') ?? readBooleanProperty(value, 'synced') + ) } -export const readYjsProviderStatus = (provider: YjsProviderLike | undefined) => - normalizeYjsProviderStatus(provider?.status) +export const readYjsProviderStatus = ( + provider: YjsProviderLike | undefined +): YjsProviderStatus | null => normalizeYjsProviderStatus(provider?.status) -export const readYjsProviderSynced = (provider: YjsProviderLike | undefined) => - normalizeYjsProviderSynced(provider?.synced) +export const readYjsProviderSynced = ( + provider: YjsProviderLike | undefined +): boolean | null => normalizeYjsProviderSynced(provider?.synced) export const connectedFromYjsProviderStatus = ( status: YjsProviderStatus | null, fallback: boolean -) => { +): boolean => { if (status === 'connected') { return true } - if (status === 'connecting' || status === 'disconnected') { return false } @@ -74,9 +64,7 @@ export const connectedFromYjsProviderStatus = ( } export const isPromiseLike = (value: unknown): value is PromiseLike => - Boolean( - value && - (typeof value === 'object' || typeof value === 'function') && - 'then' in value && - typeof value.then === 'function' - ) + value !== null && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' diff --git a/packages/slate-yjs/src/core/record.ts b/packages/slate-yjs/src/core/record.ts new file mode 100644 index 0000000000..c5fcb1af86 --- /dev/null +++ b/packages/slate-yjs/src/core/record.ts @@ -0,0 +1,4 @@ +export const isRecord = ( + value: unknown +): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/packages/slate-yjs/src/core/replacement.ts b/packages/slate-yjs/src/core/replacement.ts new file mode 100644 index 0000000000..90fe84dda6 --- /dev/null +++ b/packages/slate-yjs/src/core/replacement.ts @@ -0,0 +1,268 @@ +import type { Descendant, Operation } from 'slate' +import * as Y from 'yjs' + +import { + formatYjsTextAttributes, + getSlateYjsElementType, + removeSlateYjsAttribute, + setSlateYjsAttribute, + setSlateYjsAttributes, + type YjsAttributeRecord, + type YjsNode, +} from './attributes' +import { getYjsChildren, getYjsLength } from './document' +import { isRecord } from './record' + +type SlateElementLike = { + readonly children: readonly Descendant[] +} & Readonly> + +type SlateTextLike = { + readonly text: string +} & Readonly> + +const areJsonEqual = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right) + +export const isNoopSlateOperationForYjs = (operation: Operation): boolean => { + switch (operation.type) { + case 'replace_children': + case 'replace_fragment': + return areJsonEqual(operation.children, operation.newChildren) + default: + return false + } +} + +const isSlateText = (node: unknown): node is SlateTextLike => + isRecord(node) && typeof node.text === 'string' + +const isSlateElement = (node: unknown): node is SlateElementLike => + isRecord(node) && Array.isArray(node.children) + +const getTextAttributes = ({ + text: _text, + ...attributes +}: SlateTextLike): YjsAttributeRecord => attributes + +const getElementAttributes = ({ + children: _children, + ...attributes +}: SlateElementLike): YjsAttributeRecord => attributes + +const applyTextFormatPatch = ( + text: Y.XmlText, + patch: YjsAttributeRecord +): void => { + const length = getYjsLength(text) + + if (length === 0) { + return + } + + formatYjsTextAttributes(text, 0, length, patch) +} + +const assertYjsAttributeCanBeSet = (key: string): void => { + if (key === 'children' || key === 'text') { + throw new Error(`Cannot set the "${key}" property on a Yjs node.`) + } +} + +export const setYjsNodeAttributes = ( + node: YjsNode, + properties: YjsAttributeRecord, + newProperties: YjsAttributeRecord +): void => { + const textPatch: YjsAttributeRecord = {} + + for (const [key, value] of Object.entries(newProperties)) { + assertYjsAttributeCanBeSet(key) + + if (value === null || value === undefined) { + removeSlateYjsAttribute(node, key) + textPatch[key] = null + continue + } + + setSlateYjsAttribute(node, key, value) + + if (node instanceof Y.XmlText) { + textPatch[key] = value + } + } + + for (const key of Object.keys(properties)) { + if (Object.hasOwn(newProperties, key)) { + continue + } + assertYjsAttributeCanBeSet(key) + + removeSlateYjsAttribute(node, key) + + if (node instanceof Y.XmlText) { + textPatch[key] = null + } + } + + if (node instanceof Y.XmlText && Object.keys(textPatch).length > 0) { + applyTextFormatPatch(node, textPatch) + } +} + +export const createSplitElement = ( + original: Y.XmlElement, + properties: YjsAttributeRecord, + children: readonly YjsNode[] +): Y.XmlElement => { + const { type: _type, ...attributes } = properties + const elementType = + typeof properties.type === 'string' + ? properties.type + : getSlateYjsElementType(original) + const element = new Y.XmlElement(elementType) + + setSlateYjsAttribute(element, 'type', elementType) + setSlateYjsAttributes(element, attributes) + + if (children.length > 0) { + element.insert(0, [...children]) + } + + return element +} + +const getSharedPrefixLength = (left: string, right: string): number => { + let index = 0 + + while ( + index < left.length && + index < right.length && + left[index] === right[index] + ) { + index++ + } + + return index +} + +const getSharedSuffixLength = ( + left: string, + right: string, + prefixLength: number +): number => { + let length = 0 + + while ( + length < left.length - prefixLength && + length < right.length - prefixLength && + left.at(-1 - length) === right.at(-1 - length) + ) { + length++ + } + + return length +} + +const replaceYjsText = ( + text: Y.XmlText, + previous: string, + next: string, + attributes: YjsAttributeRecord +): void => { + const prefixLength = getSharedPrefixLength(previous, next) + const suffixLength = getSharedSuffixLength(previous, next, prefixLength) + const removeLength = previous.length - prefixLength - suffixLength + const insertText = next.slice(prefixLength, next.length - suffixLength) + + if (removeLength > 0) { + text.delete(prefixLength, removeLength) + } + + if (insertText.length > 0) { + text.insert(prefixLength, insertText, attributes) + } +} + +const canReplaceCompatibleYjsChildren = ( + children: readonly YjsNode[], + oldChildren: readonly Descendant[], + newChildren: readonly Descendant[] +): boolean => { + if ( + children.length !== oldChildren.length || + children.length !== newChildren.length + ) { + return false + } + + return children.every((child, index) => { + const oldChild = oldChildren[index] + const newChild = newChildren[index] + + if (child instanceof Y.XmlText) { + return isSlateText(oldChild) && isSlateText(newChild) + } + + if ( + child instanceof Y.XmlElement && + isSlateElement(oldChild) && + isSlateElement(newChild) + ) { + return canReplaceCompatibleYjsChildren( + getYjsChildren(child), + oldChild.children, + newChild.children + ) + } + + return false + }) +} + +export const replaceCompatibleYjsChildren = ( + children: readonly YjsNode[], + oldChildren: readonly Descendant[], + newChildren: readonly Descendant[] +): boolean => { + if (!canReplaceCompatibleYjsChildren(children, oldChildren, newChildren)) { + return false + } + + children.forEach((child, index) => { + const oldChild = oldChildren[index] + const newChild = newChildren[index] + + if (child instanceof Y.XmlText) { + if (!isSlateText(oldChild) || !isSlateText(newChild)) { + return + } + + const attributes = getTextAttributes(newChild) + + setYjsNodeAttributes(child, getTextAttributes(oldChild), attributes) + replaceYjsText(child, oldChild.text, newChild.text, attributes) + + return + } + + if ( + child instanceof Y.XmlElement && + isSlateElement(oldChild) && + isSlateElement(newChild) + ) { + setYjsNodeAttributes( + child, + getElementAttributes(oldChild), + getElementAttributes(newChild) + ) + replaceCompatibleYjsChildren( + getYjsChildren(child), + oldChild.children, + newChild.children + ) + } + }) + + return true +} diff --git a/packages/slate-yjs/src/core/selection.ts b/packages/slate-yjs/src/core/selection.ts index 63da74ae8b..85fbc5fc23 100644 --- a/packages/slate-yjs/src/core/selection.ts +++ b/packages/slate-yjs/src/core/selection.ts @@ -4,26 +4,30 @@ import * as Y from 'yjs' import { getYjsLength, getYjsNode, getYjsVisiblePath } from './document' export type YjsRelativeRange = { - anchor: Y.RelativePosition - focus: Y.RelativePosition + readonly anchor: Y.RelativePosition + readonly focus: Y.RelativePosition } +const clampTextOffset = (offset: number, length: number): number => + Math.max(0, Math.min(offset, length)) + export const slatePointToYjsRelativePosition = ( root: Y.XmlElement, point: Point -) => { +): Y.RelativePosition => { const target = getYjsNode(root, point.path) if (!(target instanceof Y.XmlText)) { throw new Error('Slate point does not target a Y.XmlText.') } - const offset = Math.max(0, Math.min(point.offset, getYjsLength(target))) + const length = getYjsLength(target) + const offset = clampTextOffset(point.offset, length) return Y.createRelativePositionFromTypeIndex( target, offset, - offset === getYjsLength(target) ? -1 : 0 + offset === length ? -1 : 0 ) } @@ -31,7 +35,7 @@ export const yjsRelativePositionToSlatePoint = ( root: Y.XmlElement, position: Y.RelativePosition ): Point | null => { - if (!root.doc) { + if (root.doc === null) { throw new Error('Yjs root must be attached to a Y.Doc.') } @@ -40,19 +44,19 @@ export const yjsRelativePositionToSlatePoint = ( root.doc ) - if (!absolute || !(absolute.type instanceof Y.XmlText)) { + if (absolute === null || !(absolute.type instanceof Y.XmlText)) { return null } const path = getYjsVisiblePath(root, absolute.type) - if (!path) { + if (path === null) { return null } return { path, - offset: Math.max(0, Math.min(absolute.index, getYjsLength(absolute.type))), + offset: clampTextOffset(absolute.index, getYjsLength(absolute.type)), } } @@ -64,6 +68,13 @@ export const slateRangeToYjsRelativeRange = ( focus: slatePointToYjsRelativePosition(root, range.focus), }) +export const yjsRelativeRangesEqual = ( + a: YjsRelativeRange, + b: YjsRelativeRange +): boolean => + Y.compareRelativePositions(a.anchor, b.anchor) && + Y.compareRelativePositions(a.focus, b.focus) + export const yjsRelativeRangeToSlateRange = ( root: Y.XmlElement, range: YjsRelativeRange @@ -71,7 +82,7 @@ export const yjsRelativeRangeToSlateRange = ( const anchor = yjsRelativePositionToSlatePoint(root, range.anchor) const focus = yjsRelativePositionToSlatePoint(root, range.focus) - if (!anchor || !focus) { + if (anchor === null || focus === null) { return null } diff --git a/packages/slate-yjs/src/core/split-history-adapter.ts b/packages/slate-yjs/src/core/split-history-adapter.ts new file mode 100644 index 0000000000..7ec5f7a1fe --- /dev/null +++ b/packages/slate-yjs/src/core/split-history-adapter.ts @@ -0,0 +1,370 @@ +import type { Operation } from 'slate' +import * as Y from 'yjs' + +import { toYjsAttributeRecord } from './attributes' +import { + getYjsNode, + getYjsNodeIf, + getYjsParent, + getYjsTextContent, + removeYjsChild, + SPLIT_UNDO_TEXT_ATTRIBUTE, +} from './document' +import { applySlateOperationToYjs } from './operations' +import { nextPath, pathsEqual } from './path' +import { + appendElementText, + clearSplitUndoTextAttribute, + findSplitUndoTextRepairs, + getTrailingSplitUndoText, + getVisibleText, + isSplitHistory, + type PendingTextSplitHistory, + SPLIT_HISTORY_META, + type SplitHistory, + type SplitUndoTextRepair, +} from './split-history' +import type { + YjsUndoManagerAdapter, + YjsUndoManagerStackItem, +} from './undo-manager-adapter' + +type SplitNodeOperation = Extract + +type YjsSplitHistoryAdapterOptions = { + readonly doc: Y.Doc + readonly historyOrigin: object + readonly isConnected: () => boolean + readonly root: Y.XmlElement + readonly undoManagerAdapter: YjsUndoManagerAdapter +} + +export type YjsSplitHistoryAdapter = { + readonly createFromOperations: ( + operations: readonly Operation[] + ) => SplitHistory | null + readonly redo: () => boolean + readonly repairAfterOfflineUndo: () => void + readonly store: (splitHistory: SplitHistory | null) => void + readonly undo: () => boolean +} + +const createSplitNodeOperation = ( + path: SplitNodeOperation['path'], + position: SplitNodeOperation['position'], + properties: SplitNodeOperation['properties'] +): SplitNodeOperation => ({ + path, + position, + properties, + type: 'split_node', +}) + +const completeSplitHistory = ( + pendingTextSplitHistory: PendingTextSplitHistory, + elementSplit: SplitNodeOperation +): SplitHistory => ({ + ...pendingTextSplitHistory, + elementPosition: elementSplit.position, + elementProperties: toYjsAttributeRecord(elementSplit.properties), +}) + +const peekSplit = ( + item: YjsUndoManagerStackItem | null +): { + item: YjsUndoManagerStackItem + splitHistory: SplitHistory +} | null => { + const splitHistory = item?.meta.get(SPLIT_HISTORY_META) + + if (item === null || !isSplitHistory(splitHistory)) { + return null + } + + return { item, splitHistory } +} + +export const createYjsSplitHistoryAdapter = ({ + doc, + historyOrigin, + isConnected, + root, + undoManagerAdapter, +}: YjsSplitHistoryAdapterOptions): YjsSplitHistoryAdapter => { + let pendingTextSplitHistory: PendingTextSplitHistory | null = null + + const isTextSplitOperation = ( + operation: Operation + ): operation is SplitNodeOperation => + operation.type === 'split_node' && + getYjsNodeIf(root, operation.path) instanceof Y.XmlText + + const isElementSplitOperation = ( + operation: Operation + ): operation is SplitNodeOperation => + operation.type === 'split_node' && + !( + operation.path.length > 0 && + getYjsNodeIf(root, operation.path) instanceof Y.XmlText + ) + + const createFromOperations = ( + operations: readonly Operation[] + ): SplitHistory | null => { + const textSplit = operations.find(isTextSplitOperation) + const elementSplit = operations.find(isElementSplitOperation) + + if (textSplit === undefined) { + const pending = pendingTextSplitHistory + + pendingTextSplitHistory = null + + if ( + elementSplit !== undefined && + pending !== null && + pathsEqual(elementSplit.path, pending.elementPath) + ) { + return completeSplitHistory(pending, elementSplit) + } + + return null + } + + const elementPath = textSplit.path.slice(0, -1) + const text = getYjsNode(root, textSplit.path) + + if (!(text instanceof Y.XmlText)) { + return null + } + + const pending: PendingTextSplitHistory = { + elementPath, + rightText: getYjsTextContent(text).slice(textSplit.position), + textPath: textSplit.path, + textProperties: toYjsAttributeRecord(textSplit.properties), + } + + if ( + elementSplit === undefined || + !pathsEqual(elementSplit.path, elementPath) + ) { + pendingTextSplitHistory = pending + + return null + } + + pendingTextSplitHistory = null + + return completeSplitHistory(pending, elementSplit) + } + + const store = (splitHistory: SplitHistory | null): void => { + if (splitHistory === null) { + return + } + + undoManagerAdapter.storeUndoMeta(SPLIT_HISTORY_META, splitHistory) + } + + const redo = (): boolean => { + const redo = peekSplit(undoManagerAdapter.peekRedo()) + + // Later redo items may still target the original right-side Yjs node. + // Let Yjs replay those split items natively so their identities survive. + if (redo === null || undoManagerAdapter.redoDepth() > 1) { + return false + } + + if (redo.splitHistory.absorbedRemoteSplit) { + undoManagerAdapter.moveRedoToUndo(redo.item) + + return true + } + + doc.transact(() => { + const text = getYjsNode(root, redo.splitHistory.textPath) + + if (!(text instanceof Y.XmlText)) { + throw new Error('Cannot redo split_node because the text node is gone.') + } + + const textValue = getYjsTextContent(text) + + if (!textValue.endsWith(redo.splitHistory.rightText)) { + throw new Error( + 'Cannot redo split_node because the right text is no longer at the split boundary.' + ) + } + + const textPosition = textValue.length - redo.splitHistory.rightText.length + const textSplit = createSplitNodeOperation( + redo.splitHistory.textPath, + textPosition, + redo.splitHistory.textProperties + ) + const elementSplit = createSplitNodeOperation( + redo.splitHistory.elementPath, + redo.splitHistory.elementPosition, + redo.splitHistory.elementProperties + ) + + applySlateOperationToYjs(root, textSplit) + applySlateOperationToYjs(root, elementSplit) + }, historyOrigin) + + undoManagerAdapter.moveRedoToUndo(redo.item) + + return true + } + + const undo = (): boolean => { + const undo = peekSplit(undoManagerAdapter.peekUndo()) + + // If another local edit was undone first, it can depend on the split-created + // right-side node. Native Yjs undo keeps that node redoable. + if (undo === null || undoManagerAdapter.redoDepth() > 0) { + return false + } + + if (undo.splitHistory.absorbedRemoteSplit) { + undoManagerAdapter.moveUndoToRedo(undo.item) + + return true + } + + const undoneWhileDisconnected = !isConnected() + let rightText = undo.splitHistory.rightText + + doc.transact(() => { + const leftText = getYjsNode(root, undo.splitHistory.textPath) + const rightElementPath = nextPath(undo.splitHistory.elementPath) + const rightElement = getYjsNode(root, rightElementPath) + const { index, parent } = getYjsParent(root, rightElementPath) + + if (!(leftText instanceof Y.XmlText)) { + throw new Error('Cannot undo split_node because the left text is gone.') + } + if (!(rightElement instanceof Y.XmlElement)) { + throw new Error( + 'Cannot undo split_node because the right element is gone.' + ) + } + + rightText = appendElementText(root, leftText, rightElement, { + [SPLIT_UNDO_TEXT_ATTRIBUTE]: undoneWhileDisconnected, + }) + removeYjsChild(root, parent, index) + }, historyOrigin) + + undo.splitHistory.rightText = rightText + undo.splitHistory.undoneWhileDisconnected = undoneWhileDisconnected + undoManagerAdapter.moveUndoToRedo(undo.item) + + return true + } + + const hasRemoteSplitBoundary = (splitHistory: SplitHistory): boolean => { + try { + const rightElement = getYjsNode(root, nextPath(splitHistory.elementPath)) + + return getVisibleText(root, rightElement).startsWith( + splitHistory.rightText + ) + } catch { + return false + } + } + + const getSplitUndoTextRepair = ( + splitHistory: SplitHistory + ): SplitUndoTextRepair | null => { + if (splitHistory.rightText.length === 0) { + return null + } + + try { + const leftText = getYjsNode(root, splitHistory.textPath) + + if (!(leftText instanceof Y.XmlText)) { + return null + } + + const trailing = getTrailingSplitUndoText(leftText) + + if (trailing === null || trailing.value !== splitHistory.rightText) { + return null + } + + return { + ...trailing, + hasRemoteSplitBoundary: hasRemoteSplitBoundary(splitHistory), + text: leftText, + } + } catch { + return null + } + } + + const leftTextEndsWithSplitRightText = ( + splitHistory: SplitHistory + ): boolean => { + try { + const leftText = getYjsNode(root, splitHistory.textPath) + + return ( + leftText instanceof Y.XmlText && + getYjsTextContent(leftText).endsWith(splitHistory.rightText) + ) + } catch { + return false + } + } + + const repairAfterOfflineUndo = (): void => { + const repairs = findSplitUndoTextRepairs(root) + const redo = peekSplit(undoManagerAdapter.peekRedo()) + const splitHistory = redo?.splitHistory + const activeRepair = splitHistory?.undoneWhileDisconnected + ? getSplitUndoTextRepair(splitHistory) + : null + + if (repairs.length > 0) { + doc.transact(() => { + for (const repair of repairs) { + if (repair.hasRemoteSplitBoundary) { + repair.text.delete(repair.offset, repair.length) + } else { + clearSplitUndoTextAttribute( + repair.text, + repair.offset, + repair.length + ) + } + } + }, historyOrigin) + } + + if (!splitHistory?.undoneWhileDisconnected) { + return + } + + if ( + activeRepair?.hasRemoteSplitBoundary || + (activeRepair === null && + hasRemoteSplitBoundary(splitHistory) && + !leftTextEndsWithSplitRightText(splitHistory)) + ) { + splitHistory.absorbedRemoteSplit = true + } else { + splitHistory.undoneWhileDisconnected = false + } + } + + return { + createFromOperations, + redo, + repairAfterOfflineUndo, + store, + undo, + } +} diff --git a/packages/slate-yjs/src/core/split-history.ts b/packages/slate-yjs/src/core/split-history.ts index 3a6be5cfb8..03b0d7bc5a 100644 --- a/packages/slate-yjs/src/core/split-history.ts +++ b/packages/slate-yjs/src/core/split-history.ts @@ -1,22 +1,28 @@ import type { Path } from 'slate' import * as Y from 'yjs' +import { + formatYjsTextAttributes, + type YjsAttributeRecord, + type YjsNode, +} from './attributes' import { getYjsLength, - getYjsNode, getYjsTextContent, getYjsVisibleChildren, SPLIT_UNDO_TEXT_ATTRIBUTE, } from './document' +import { isRecord } from './record' +import { isNonEmptyYjsTextDeltaPart } from './text-delta' export type SplitHistory = { absorbedRemoteSplit?: boolean - elementPath: Path - elementPosition: number - elementProperties: Record + readonly elementPath: Path + readonly elementPosition: number + readonly elementProperties: YjsAttributeRecord rightText: string - textPath: Path - textProperties: Record + readonly textPath: Path + readonly textProperties: YjsAttributeRecord undoneWhileDisconnected?: boolean } @@ -27,16 +33,44 @@ export type PendingTextSplitHistory = Omit< export const SPLIT_HISTORY_META = 'slate-yjs:split-history' +export type SplitUndoTextRepair = { + readonly hasRemoteSplitBoundary: boolean + readonly length: number + readonly offset: number + readonly text: Y.XmlText +} + +type TrailingSplitUndoText = { + readonly length: number + readonly offset: number + readonly value: string +} + +const isSlateIndex = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 0 + +const isSlatePath = (value: unknown): value is Path => + Array.isArray(value) && value.every(isSlateIndex) + +const isOptionalBoolean = (value: unknown): value is boolean | undefined => + value === undefined || typeof value === 'boolean' + +const createTrailingSplitUndoText = ( + value: string, + offset: number +): TrailingSplitUndoText | null => + value.length > 0 ? { length: value.length, offset, value } : null + const appendTextContent = ( target: Y.XmlText, source: Y.XmlText, - extraAttributes: Record = {} -) => { + extraAttributes: YjsAttributeRecord = {} +): string => { let offset = getYjsLength(target) let insertedText = '' for (const delta of source.toDelta()) { - if (typeof delta.insert !== 'string' || delta.insert.length === 0) { + if (!isNonEmptyYjsTextDeltaPart(delta)) { continue } @@ -55,8 +89,8 @@ export const appendElementText = ( root: Y.XmlElement, target: Y.XmlText, element: Y.XmlElement, - extraAttributes: Record = {} -) => { + extraAttributes: YjsAttributeRecord = {} +): string => { let insertedText = '' for (const child of getYjsVisibleChildren(root, element)) { @@ -72,7 +106,7 @@ export const appendElementText = ( const findLastVisibleText = ( root: Y.XmlElement, - node: Y.XmlElement | Y.XmlText + node: YjsNode ): Y.XmlText | null => { if (node instanceof Y.XmlText) { return node @@ -82,9 +116,14 @@ const findLastVisibleText = ( for (let index = children.length - 1; index >= 0; index--) { const child = children[index] - const text = child ? findLastVisibleText(root, child) : null - if (text) { + if (child === undefined) { + continue + } + + const text = findLastVisibleText(root, child) + + if (text !== null) { return text } } @@ -92,13 +131,15 @@ const findLastVisibleText = ( return null } -export const getTrailingSplitUndoText = (text: Y.XmlText) => { +export const getTrailingSplitUndoText = ( + text: Y.XmlText +): TrailingSplitUndoText | null => { let offset = getYjsLength(text) let value = '' for (const delta of [...text.toDelta()].reverse()) { - if (typeof delta.insert !== 'string' || delta.insert.length === 0) { - return value ? { length: value.length, offset, value } : null + if (!isNonEmptyYjsTextDeltaPart(delta)) { + return createTrailingSplitUndoText(value, offset) } if (delta.attributes?.[SPLIT_UNDO_TEXT_ATTRIBUTE] === true) { @@ -110,23 +151,20 @@ export const getTrailingSplitUndoText = (text: Y.XmlText) => { break } - return value ? { length: value.length, offset, value } : null + return createTrailingSplitUndoText(value, offset) } export const clearSplitUndoTextAttribute = ( text: Y.XmlText, offset: number, length: number -) => { - text.format(offset, length, { +): void => { + formatYjsTextAttributes(text, offset, length, { [SPLIT_UNDO_TEXT_ATTRIBUTE]: null, - } as unknown as Record) + }) } -export const getVisibleText = ( - root: Y.XmlElement, - node: Y.XmlElement | Y.XmlText -): string => { +export const getVisibleText = (root: Y.XmlElement, node: YjsNode): string => { if (node instanceof Y.XmlText) { return getYjsTextContent(node) } @@ -136,15 +174,12 @@ export const getVisibleText = ( .join('') } -export const findSplitUndoTextRepairs = (root: Y.XmlElement) => { - const repairs: Array<{ - hasRemoteSplitBoundary: boolean - length: number - offset: number - text: Y.XmlText - }> = [] +export const findSplitUndoTextRepairs = ( + root: Y.XmlElement +): SplitUndoTextRepair[] => { + const repairs: SplitUndoTextRepair[] = [] - const visit = (parent: Y.XmlElement) => { + const visit = (parent: Y.XmlElement): void => { const children = getYjsVisibleChildren(root, parent) for (let index = 0; index < children.length; index++) { @@ -156,13 +191,15 @@ export const findSplitUndoTextRepairs = (root: Y.XmlElement) => { const leftText = findLastVisibleText(root, left) const right = children[index + 1] - const trailing = leftText ? getTrailingSplitUndoText(leftText) : null + const trailing = + leftText === null ? null : getTrailingSplitUndoText(leftText) - if (leftText && trailing) { + if (leftText !== null && trailing !== null) { repairs.push({ - hasRemoteSplitBoundary: right - ? getVisibleText(root, right).startsWith(trailing.value) - : false, + hasRemoteSplitBoundary: + right === undefined + ? false + : getVisibleText(root, right).startsWith(trailing.value), length: trailing.length, offset: trailing.offset, text: leftText, @@ -182,31 +219,19 @@ export const findSplitUndoTextRepairs = (root: Y.XmlElement) => { return repairs } -export const isSplitHistory = (value: unknown): value is SplitHistory => - typeof value === 'object' && - value !== null && - Array.isArray((value as SplitHistory).elementPath) && - Array.isArray((value as SplitHistory).textPath) && - typeof (value as SplitHistory).rightText === 'string' && - typeof (value as SplitHistory).elementPosition === 'number' - -export const nextPath = (path: Path) => { - const index = path.at(-1) - - if (index === undefined) { - throw new Error('Cannot get a next path for the root.') +export const isSplitHistory = (value: unknown): value is SplitHistory => { + if (!isRecord(value)) { + return false } - return [...path.slice(0, -1), index + 1] + return ( + isSlatePath(value.elementPath) && + isSlatePath(value.textPath) && + typeof value.rightText === 'string' && + isSlateIndex(value.elementPosition) && + isRecord(value.elementProperties) && + isRecord(value.textProperties) && + isOptionalBoolean(value.absorbedRemoteSplit) && + isOptionalBoolean(value.undoneWhileDisconnected) + ) } - -export const getYjsNodeIf = (root: Y.XmlElement, path: Path) => { - try { - return getYjsNode(root, path) - } catch { - return null - } -} - -export const pathsEqual = (a: Path, b: Path) => - a.length === b.length && a.every((part, index) => part === b[index]) diff --git a/packages/slate-yjs/src/core/text-delta.ts b/packages/slate-yjs/src/core/text-delta.ts new file mode 100644 index 0000000000..e97db299df --- /dev/null +++ b/packages/slate-yjs/src/core/text-delta.ts @@ -0,0 +1,12 @@ +type YjsTextDeltaPart = { + readonly attributes?: Readonly> + readonly insert?: unknown +} + +export const getYjsTextDeltaPartText = (part: YjsTextDeltaPart): string => + typeof part.insert === 'string' ? part.insert : '' + +export const isNonEmptyYjsTextDeltaPart = ( + part: YjsTextDeltaPart +): part is YjsTextDeltaPart & { readonly insert: string } => + typeof part.insert === 'string' && part.insert.length > 0 diff --git a/packages/slate-yjs/src/core/types.ts b/packages/slate-yjs/src/core/types.ts index 55121dd28c..16a168a1a0 100644 --- a/packages/slate-yjs/src/core/types.ts +++ b/packages/slate-yjs/src/core/types.ts @@ -2,19 +2,27 @@ import type { Range, Value } from 'slate' import type * as Y from 'yjs' export type YjsAwarenessChange = { - added: number[] - removed: number[] - updated: number[] + readonly added: readonly number[] + readonly removed: readonly number[] + readonly updated: readonly number[] } +export type YjsAwarenessState = Readonly> + export type YjsAwarenessLike = { - clientID?: number - doc?: { clientID: number } - getLocalState: () => Record | null - getStates: () => Map> - off?: (event: 'change', handler: (event: YjsAwarenessChange) => void) => void - on?: (event: 'change', handler: (event: YjsAwarenessChange) => void) => void - setLocalStateField: (field: string, value: unknown) => void + readonly clientID?: number + readonly doc?: { readonly clientID: number } + readonly getLocalState: () => YjsAwarenessState | null + readonly getStates: () => ReadonlyMap + readonly off?: ( + event: 'change', + handler: (event: YjsAwarenessChange) => void + ) => void + readonly on?: ( + event: 'change', + handler: (event: YjsAwarenessChange) => void + ) => void + readonly setLocalStateField: (field: string, value: unknown) => void } export type YjsProviderStatus = @@ -26,47 +34,63 @@ export type YjsProviderStatus = export type YjsProviderStatusPayload = | YjsProviderStatus | { - status: YjsProviderStatus + readonly status: YjsProviderStatus } export type YjsProviderSyncedPayload = | boolean | { - state: boolean + readonly state: boolean } | { - synced: boolean + readonly synced: boolean } export type YjsProviderEvent = 'status' | 'sync' | 'synced' +export type YjsProviderStatusHandler = ( + status: YjsProviderStatusPayload +) => void + +export type YjsProviderSyncedHandler = ( + synced: YjsProviderSyncedPayload +) => void + export type YjsProviderEventHandler = - | ((status: YjsProviderStatusPayload) => void) - | ((synced: YjsProviderSyncedPayload) => void) + | YjsProviderStatusHandler + | YjsProviderSyncedHandler export type YjsProviderLike = { - awareness?: YjsAwarenessLike - connect?: () => Promise | unknown - destroy?: () => void - disconnect?: () => Promise | unknown - doc?: Y.Doc - off?: (event: YjsProviderEvent, handler: YjsProviderEventHandler) => void - on?: (event: YjsProviderEvent, handler: YjsProviderEventHandler) => void + readonly awareness?: YjsAwarenessLike + readonly connect?: () => Promise | unknown + readonly destroy?: () => void + readonly disconnect?: () => Promise | unknown + readonly doc?: Y.Doc + readonly off?: ( + event: YjsProviderEvent, + handler: YjsProviderEventHandler + ) => void + readonly on?: ( + event: YjsProviderEvent, + handler: YjsProviderEventHandler + ) => void status?: YjsProviderStatus synced?: boolean } export type YjsAwarenessSelection = { - anchor: unknown - focus: unknown + readonly anchor: unknown + readonly focus: unknown } +export type YjsRemoteCursorData = Readonly> + export type YjsRemoteCursor< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, > = { - clientId: number - selection: Range | null - data?: TCursorData + readonly clientId: number + readonly selection: Range | null + readonly data?: TCursorData } export type YjsTraceMode = @@ -74,66 +98,80 @@ export type YjsTraceMode = | 'remote-reconcile' | 'seed' | 'traceable-fallback' - | 'unsupported' + +export type YjsTraceFallback = + | 'empty-text-merge-elided' + | 'incompatible-structural-merge-elided' + | 'missing-move-destination-elided' + | 'missing-move-source-elided' + | 'replace-children-virtual-removal' + | 'replace-fragment-scoped-replace-identity-risk' + | 'text-merge-preserve-yjs-boundary' + | 'virtual-merge-ref' + | 'virtual-move-parent-remove' + | 'virtual-move-placeholder' + | 'virtual-move-ref' + | 'virtual-unwrap-ref' + | 'virtual-unwrap-wrapper-remove' export type YjsTraceEntry = { - fallback?: string - mode: YjsTraceMode - operationType?: string + readonly fallback?: YjsTraceFallback + readonly mode: YjsTraceMode + readonly operationType?: string } export type YjsExtensionOptions = { - autoSendSelection?: boolean - awareness?: YjsAwarenessLike - awarenessDataField?: string - awarenessSelectionField?: string - clientId?: number | string - destroyProviderOnUnmount?: boolean - doc?: Y.Doc - provider?: YjsProviderLike - rootName?: string - seedProviderOnSync?: boolean + readonly autoSendSelection?: boolean + readonly awareness?: YjsAwarenessLike + readonly awarenessDataField?: string + readonly awarenessSelectionField?: string + readonly clientId?: number | string + readonly destroyProviderOnUnmount?: boolean + readonly doc?: Y.Doc + readonly provider?: YjsProviderLike + readonly rootName?: string + readonly seedProviderOnSync?: boolean } export type YjsState = { - awarenessRevision: () => number - clientId: () => number | string - connected: () => boolean - doc: () => Y.Doc - paused: () => boolean - providerRevision: () => number - providerStatus: () => YjsProviderStatus | null - providerSynced: () => boolean | null - remoteCursor: < - TCursorData extends Record = Record, + readonly awarenessRevision: () => number + readonly clientId: () => number | string + readonly connected: () => boolean + readonly doc: () => Y.Doc + readonly paused: () => boolean + readonly providerRevision: () => number + readonly providerStatus: () => YjsProviderStatus | null + readonly providerSynced: () => boolean | null + readonly remoteCursor: < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, >( clientId: number ) => YjsRemoteCursor | null - remoteCursors: < - TCursorData extends Record = Record, - >() => YjsRemoteCursor[] - root: () => Y.XmlElement - subscribeAwareness: (listener: () => void) => () => void - subscribeProvider: (listener: () => void) => () => void - trace: () => readonly YjsTraceEntry[] + readonly remoteCursors: < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + >() => readonly YjsRemoteCursor[] + readonly root: () => Y.XmlElement + readonly subscribeAwareness: (listener: () => void) => () => void + readonly subscribeProvider: (listener: () => void) => () => void + readonly trace: () => readonly YjsTraceEntry[] } export type YjsTx = { - clearSelection: () => void - clearTrace: () => void - connect: () => void - disconnect: () => void - pause: () => void - reconcile: () => void - reconnect: () => void - redo: () => void - resume: () => void - sendCursorData: (data: Record | null) => void - sendSelection: ( + readonly clearSelection: () => void + readonly clearTrace: () => void + readonly connect: () => void + readonly disconnect: () => void + readonly pause: () => void + readonly reconcile: () => void + readonly reconnect: () => void + readonly redo: () => void + readonly resume: () => void + readonly sendCursorData: (data: YjsRemoteCursorData | null) => void + readonly sendSelection: ( range?: Range | null, - data?: Record | null + data?: YjsRemoteCursorData | null ) => void - undo: () => void + readonly undo: () => void } declare module 'slate' { diff --git a/packages/slate-yjs/src/core/undo-manager-adapter.ts b/packages/slate-yjs/src/core/undo-manager-adapter.ts index a527a17fc5..a24667e723 100644 --- a/packages/slate-yjs/src/core/undo-manager-adapter.ts +++ b/packages/slate-yjs/src/core/undo-manager-adapter.ts @@ -1,22 +1,29 @@ import type * as Y from 'yjs' +import { isRecord } from './record' + export const SUPPORTED_YJS_UNDO_MANAGER_VERSION = '13.6.30' export type YjsUndoManagerStackItem = { - meta: Map + readonly meta: Map } -type YjsUndoManagerWithStacks = Y.UndoManager & { - redoStack: YjsUndoManagerStackItem[] - undoStack: YjsUndoManagerStackItem[] +export type YjsUndoManagerAdapter = { + readonly moveRedoToUndo: (item: YjsUndoManagerStackItem) => void + readonly moveUndoToRedo: (item: YjsUndoManagerStackItem) => void + readonly peekRedo: () => YjsUndoManagerStackItem | null + readonly peekUndo: () => YjsUndoManagerStackItem | null + readonly redoDepth: () => number + readonly storeUndoMeta: (key: unknown, value: unknown) => void } const isStackItem = (value: unknown): value is YjsUndoManagerStackItem => - typeof value === 'object' && - value !== null && - (value as YjsUndoManagerStackItem).meta instanceof Map + isRecord(value) && value.meta instanceof Map -const assertStack = (value: unknown, name: string) => { +const assertStack = ( + value: unknown, + name: string +): YjsUndoManagerStackItem[] => { if (!Array.isArray(value) || value.some((item) => !isStackItem(item))) { throw new Error( `Unsupported Yjs UndoManager ${name} contract. @slate/yjs pins yjs@${SUPPORTED_YJS_UNDO_MANAGER_VERSION}.` @@ -26,30 +33,50 @@ const assertStack = (value: unknown, name: string) => { return value } -export const createYjsUndoManagerAdapter = (undoManager: Y.UndoManager) => { - const manager = undoManager as YjsUndoManagerWithStacks - const undo = () => assertStack(manager.undoStack, 'undo') - const redo = () => assertStack(manager.redoStack, 'redo') +const readUndoManagerStack = ( + undoManager: Y.UndoManager, + name: 'redo' | 'undo' +): YjsUndoManagerStackItem[] => { + const stack = isRecord(undoManager) + ? name === 'undo' + ? undoManager.undoStack + : undoManager.redoStack + : undefined + + return assertStack(stack, name) +} + +const popExpectedStackItem = ( + stack: YjsUndoManagerStackItem[], + item: YjsUndoManagerStackItem, + message: string +): void => { + const popped = stack.pop() + + if (popped !== item) { + throw new Error(message) + } +} + +export const createYjsUndoManagerAdapter = ( + undoManager: Y.UndoManager +): YjsUndoManagerAdapter => { + const undo = (): YjsUndoManagerStackItem[] => + readUndoManagerStack(undoManager, 'undo') + const redo = (): YjsUndoManagerStackItem[] => + readUndoManagerStack(undoManager, 'redo') return { moveRedoToUndo(item: YjsUndoManagerStackItem) { const stack = redo() - const popped = stack.pop() - - if (popped !== item) { - throw new Error('Cannot move a non-top redo item.') - } + popExpectedStackItem(stack, item, 'Cannot move a non-top redo item.') undo().push(item) }, moveUndoToRedo(item: YjsUndoManagerStackItem) { const stack = undo() - const popped = stack.pop() - - if (popped !== item) { - throw new Error('Cannot move a non-top undo item.') - } + popExpectedStackItem(stack, item, 'Cannot move a non-top undo item.') redo().push(item) }, peekRedo() { diff --git a/packages/slate-yjs/src/react/index.ts b/packages/slate-yjs/src/react/index.ts index 28de417906..1449979209 100644 --- a/packages/slate-yjs/src/react/index.ts +++ b/packages/slate-yjs/src/react/index.ts @@ -7,172 +7,223 @@ import { useState, useSyncExternalStore, } from 'react' -import type { Editor, EditorCoreStateView, Range } from 'slate' +import type { Editor, Range } from 'slate' import { createRangeDecorationSource, type SlateDecorationSource, } from 'slate-react' -import type { YjsProviderStatus, YjsRemoteCursor, YjsState } from '../core' - -type YjsStateView = EditorCoreStateView & { - yjs: YjsState -} - -type YjsDOMRangeResolver = Editor & { - api?: { - dom?: { - isFocused?: () => boolean - resolveRangeRect?: (range: Range) => DOMRect | null - } - } +import type { + YjsProviderStatus, + YjsRemoteCursor, + YjsRemoteCursorData, + YjsState, +} from '../core' +import { getEditorYjsState } from '../core/editor-yjs' +import { pathsEqual } from '../core/path' +import { isRecord } from '../core/record' + +type YjsDOMApi = { + readonly isFocused?: () => boolean + readonly resolveRangeRect?: (range: Range) => unknown } export type YjsRemoteCursorDecorationData< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, > = { - clientId: number - cursor: YjsRemoteCursor - data?: TCursorData + readonly clientId: number + readonly cursor: YjsRemoteCursor + readonly data?: TCursorData } export type UseYjsRemoteCursorDecorationSourceOptions< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, TDecorationData = YjsRemoteCursorDecorationData, > = { - decorate?: (cursor: YjsRemoteCursor) => TDecorationData + readonly decorate?: (cursor: YjsRemoteCursor) => TDecorationData /** Values that should recompute decoration data when decorate closes over React state. */ - deps?: readonly unknown[] - id?: string + readonly deps?: readonly unknown[] + readonly id?: string } export type YjsRemoteCursorOverlayPosition< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, TPositionData = YjsRemoteCursorDecorationData, > = { - clientId: number - cursor: YjsRemoteCursor - data: TPositionData - range: Range - rect: DOMRect | null + readonly clientId: number + readonly cursor: YjsRemoteCursor + readonly data: TPositionData + readonly range: Range + readonly rect: DOMRect | null } export type UseYjsRemoteCursorOverlayPositionsOptions< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, TPositionData = YjsRemoteCursorDecorationData, > = { - data?: (cursor: YjsRemoteCursor) => TPositionData + readonly data?: (cursor: YjsRemoteCursor) => TPositionData /** Values that should recompute overlay data when data closes over React state. */ - deps?: readonly unknown[] + readonly deps?: readonly unknown[] +} + +type YjsRemoteCursorRange< + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, +> = { + readonly cursor: YjsRemoteCursor + readonly range: Range } const DEFAULT_CURSOR_DECORATION_SOURCE_ID = 'yjs-remote-cursors' +const DOM_RECT_FIELDS = [ + 'bottom', + 'height', + 'left', + 'right', + 'top', + 'width', + 'x', + 'y', +] as const +const EMPTY_DEPS: readonly unknown[] = [] const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect -const readYjsState = (editor: Editor, selector: (state: YjsState) => T) => - editor.read((state) => selector((state as YjsStateView).yjs)) +const readYjsState = (editor: Editor, selector: (state: YjsState) => T): T => + editor.read((state) => selector(getEditorYjsState(state))) + +const useYjsRevision = ( + editor: Editor, + subscribe: (state: YjsState, listener: () => void) => () => void, + getSnapshot: (editor: Editor) => number +): number => + useSyncExternalStore( + (listener) => readYjsState(editor, (state) => subscribe(state, listener)), + () => getSnapshot(editor), + () => getSnapshot(editor) + ) + +const useYjsAwarenessValue = ( + editor: Editor, + selector: (state: YjsState) => T +): T => { + useYjsAwarenessRevision(editor) + + return readYjsState(editor, selector) +} + +const useYjsProviderValue = ( + editor: Editor, + selector: (state: YjsState) => T +): T => { + useYjsProviderRevision(editor) + + return readYjsState(editor, selector) +} const createCursorData = < - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, >( cursor: YjsRemoteCursor -): YjsRemoteCursorDecorationData => { - const data: YjsRemoteCursorDecorationData = { - clientId: cursor.clientId, - cursor, - } +): YjsRemoteCursorDecorationData => ({ + clientId: cursor.clientId, + cursor, + ...(cursor.data === undefined ? {} : { data: cursor.data }), +}) + +const createDefaultCursorData = < + TCursorData extends YjsRemoteCursorData, + TData, +>( + cursor: YjsRemoteCursor +): TData => createCursorData(cursor) as TData + +const isYjsDOMApi = (value: unknown): value is YjsDOMApi => + isRecord(value) && + (value.isFocused === undefined || typeof value.isFocused === 'function') && + (value.resolveRangeRect === undefined || + typeof value.resolveRangeRect === 'function') + +const isDOMRectLike = (value: unknown): value is DOMRect => + isRecord(value) && + DOM_RECT_FIELDS.every((field) => typeof value[field] === 'number') - if (cursor.data !== undefined) { - data.data = cursor.data +const getYjsDOMApi = (editor: Editor): YjsDOMApi | undefined => { + const api = isRecord(editor) ? editor.api : undefined + + if (!isRecord(api)) { + return undefined } - return data + return isYjsDOMApi(api.dom) ? api.dom : undefined } -const resolveCursorRect = (editor: Editor, range: Range) => { - const resolveRangeRect = (editor as YjsDOMRangeResolver).api?.dom - ?.resolveRangeRect +const resolveCursorRect = (editor: Editor, range: Range): DOMRect | null => { + const resolveRangeRect = getYjsDOMApi(editor)?.resolveRangeRect - if (!resolveRangeRect) { + if (resolveRangeRect === undefined) { return null } try { - return resolveRangeRect(range) + const rect = resolveRangeRect(range) + + return isDOMRectLike(rect) ? rect : null } catch { return null } } -const isEditorFocused = (editor: Editor) => - Boolean((editor as YjsDOMRangeResolver).api?.dom?.isFocused?.()) +const isEditorFocused = (editor: Editor): boolean => + getYjsDOMApi(editor)?.isFocused?.() === true -const pointsEqual = (a: Range['anchor'], b: Range['anchor']) => - a.offset === b.offset && - a.path.length === b.path.length && - a.path.every((part, index) => part === b.path[index]) +const pointsEqual = (a: Range['anchor'], b: Range['anchor']): boolean => + a.offset === b.offset && pathsEqual(a.path, b.path) -const rangesEqual = (a: Range, b: Range) => +const rangesEqual = (a: Range, b: Range): boolean => pointsEqual(a.anchor, b.anchor) && pointsEqual(a.focus, b.focus) -const rectsEqual = (a: DOMRect | null, b: DOMRect | null) => { +const rectsEqual = (a: DOMRect | null, b: DOMRect | null): boolean => { if (a === b) { return true } - if (!a || !b) { + if (a === null || b === null) { return false } - return ( - a.bottom === b.bottom && - a.height === b.height && - a.left === b.left && - a.right === b.right && - a.top === b.top && - a.width === b.width && - a.x === b.x && - a.y === b.y - ) + return DOM_RECT_FIELDS.every((field) => a[field] === b[field]) } -const shallowEqual = (a: unknown, b: unknown) => { +const shallowEqual = (a: unknown, b: unknown): boolean => { if (Object.is(a, b)) { return true } - if ( - typeof a !== 'object' || - a === null || - typeof b !== 'object' || - b === null - ) { + if (!isRecord(a) || !isRecord(b)) { return false } - const aRecord = a as Record - const bRecord = b as Record - const aKeys = Object.keys(aRecord) + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) return ( - aKeys.length === Object.keys(bRecord).length && - aKeys.every((key) => Object.is(aRecord[key], bRecord[key])) + aKeys.length === bKeys.length && + aKeys.every((key) => Object.is(a[key], b[key])) ) } const overlayPositionsEqual = < - TCursorData extends Record, + TCursorData extends YjsRemoteCursorData, TPositionData, >( a: readonly YjsRemoteCursorOverlayPosition[], b: readonly YjsRemoteCursorOverlayPosition[] -) => +): boolean => a.length === b.length && a.every((position, index) => { const next = b[index] return ( - !!next && + next !== undefined && position.clientId === next.clientId && rangesEqual(position.range, next.range) && rectsEqual(position.rect, next.rect) && @@ -180,41 +231,55 @@ const overlayPositionsEqual = < ) }) -const readYjsRemoteCursorOverlayPositions = < - TCursorData extends Record = Record, - TPositionData = YjsRemoteCursorDecorationData, +const getRemoteCursorRange = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, >( - editor: Editor, - options: UseYjsRemoteCursorOverlayPositionsOptions -): YjsRemoteCursorOverlayPosition[] => + cursor: YjsRemoteCursor +): YjsRemoteCursorRange | null => { + const range = cursor.selection + + return range === null ? null : { cursor, range } +} + +const readYjsRemoteCursorRanges = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, +>( + editor: Editor +): readonly YjsRemoteCursorRange[] => readYjsState(editor, (state) => state.remoteCursors().flatMap((cursor) => { - const range = cursor.selection - - if (!range) { - return [] - } - - const data = options.data - ? options.data(cursor) - : (createCursorData(cursor) as TPositionData) - - return [ - { - clientId: cursor.clientId, - cursor, - data, - range, - rect: resolveCursorRect(editor, range), - }, - ] + const range = getRemoteCursorRange(cursor) + + return range === null ? [] : [range] }) ) -export const getYjsAwarenessRevision = (editor: Editor) => +const readYjsRemoteCursorOverlayPositions = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + TPositionData = YjsRemoteCursorDecorationData, +>( + editor: Editor, + options: UseYjsRemoteCursorOverlayPositionsOptions +): readonly YjsRemoteCursorOverlayPosition[] => + readYjsRemoteCursorRanges(editor).map(({ cursor, range }) => { + const data = + options.data === undefined + ? createDefaultCursorData(cursor) + : options.data(cursor) + + return { + clientId: cursor.clientId, + cursor, + data, + range, + rect: resolveCursorRect(editor, range), + } + }) + +export const getYjsAwarenessRevision = (editor: Editor): number => readYjsState(editor, (state) => state.awarenessRevision()) -export const getYjsProviderRevision = (editor: Editor) => +export const getYjsProviderRevision = (editor: Editor): number => readYjsState(editor, (state) => state.providerRevision()) export const getYjsProviderStatus = ( @@ -225,56 +290,48 @@ export const getYjsProviderStatus = ( export const getYjsProviderSynced = (editor: Editor): boolean | null => readYjsState(editor, (state) => state.providerSynced()) -export function useYjsAwarenessRevision(editor: Editor) { - return useSyncExternalStore( - (listener) => - readYjsState(editor, (state) => state.subscribeAwareness(listener)), - () => getYjsAwarenessRevision(editor), - () => getYjsAwarenessRevision(editor) +export function useYjsAwarenessRevision(editor: Editor): number { + return useYjsRevision( + editor, + (state, listener) => state.subscribeAwareness(listener), + getYjsAwarenessRevision ) } -export function useYjsProviderRevision(editor: Editor) { - return useSyncExternalStore( - (listener) => - readYjsState(editor, (state) => state.subscribeProvider(listener)), - () => getYjsProviderRevision(editor), - () => getYjsProviderRevision(editor) +export function useYjsProviderRevision(editor: Editor): number { + return useYjsRevision( + editor, + (state, listener) => state.subscribeProvider(listener), + getYjsProviderRevision ) } export function useYjsProviderStatus(editor: Editor): YjsProviderStatus | null { - useYjsProviderRevision(editor) - - return getYjsProviderStatus(editor) + return useYjsProviderValue(editor, (state) => state.providerStatus()) } export function useYjsProviderSynced(editor: Editor): boolean | null { - useYjsProviderRevision(editor) - - return getYjsProviderSynced(editor) + return useYjsProviderValue(editor, (state) => state.providerSynced()) } export function useYjsRemoteCursor< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, >(editor: Editor, clientId: number): YjsRemoteCursor | null { - useYjsAwarenessRevision(editor) - - return readYjsState(editor, (state) => + return useYjsAwarenessValue(editor, (state) => state.remoteCursor(clientId) ) } export function useYjsRemoteCursors< - TCursorData extends Record = Record, ->(editor: Editor): YjsRemoteCursor[] { - useYjsAwarenessRevision(editor) - - return readYjsState(editor, (state) => state.remoteCursors()) + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, +>(editor: Editor): readonly YjsRemoteCursor[] { + return useYjsAwarenessValue(editor, (state) => + state.remoteCursors() + ) } export function useYjsRemoteCursorDecorationSource< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, TDecorationData = YjsRemoteCursorDecorationData, >( editor: Editor, @@ -284,7 +341,7 @@ export function useYjsRemoteCursorDecorationSource< > = {} ): SlateDecorationSource { const awarenessRevision = useYjsAwarenessRevision(editor) - const decorateRefreshDeps = options.deps ?? [] + const decorateRefreshDeps = options.deps ?? EMPTY_DEPS const optionsRef = useRef(options) const id = options.id ?? DEFAULT_CURSOR_DECORATION_SOURCE_ID optionsRef.current = options @@ -294,27 +351,22 @@ export function useYjsRemoteCursorDecorationSource< createRangeDecorationSource(editor, { id, read: () => - readYjsState(editor, (state) => - state.remoteCursors().flatMap((cursor) => { - const range = cursor.selection - - if (!range) { - return [] - } - + readYjsRemoteCursorRanges(editor).map( + ({ cursor, range }) => { const decorate = optionsRef.current.decorate - const data = decorate - ? decorate(cursor) - : (createCursorData(cursor) as TDecorationData) - - return [ - { - data, - key: `${id}:${cursor.clientId}`, - range, - }, - ] - }) + const data = + decorate === undefined + ? createDefaultCursorData( + cursor + ) + : decorate(cursor) + + return { + data, + key: `${id}:${cursor.clientId}`, + range, + } + } ), }), [editor, id] @@ -334,7 +386,7 @@ export function useYjsRemoteCursorDecorationSource< } export function useYjsRemoteCursorOverlayPositions< - TCursorData extends Record = Record, + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, TPositionData = YjsRemoteCursorDecorationData, >( editor: Editor, @@ -347,7 +399,7 @@ export function useYjsRemoteCursorOverlayPositions< () => void, ] { const awarenessRevision = useYjsAwarenessRevision(editor) - const dataRefreshDeps = options.deps ?? [] + const dataRefreshDeps = options.deps ?? EMPTY_DEPS const animationFrameRef = useRef(null) const optionsRef = useRef(options) optionsRef.current = options @@ -379,7 +431,10 @@ export function useYjsRemoteCursorOverlayPositions< const refreshAfterEditorLayout = useCallback(() => { refresh() - if (typeof window === 'undefined' || !window.requestAnimationFrame) { + if ( + typeof window === 'undefined' || + typeof window.requestAnimationFrame !== 'function' + ) { return } @@ -419,5 +474,5 @@ export function useYjsRemoteCursorOverlayPositions< } }, [refresh]) - return [positions, refresh] as const + return [positions, refresh] } diff --git a/packages/slate-yjs/test/attributes-contract.spec.ts b/packages/slate-yjs/test/attributes-contract.spec.ts new file mode 100644 index 0000000000..617fd681c0 --- /dev/null +++ b/packages/slate-yjs/test/attributes-contract.spec.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import * as Y from 'yjs' + +import { getYjsAttributes, setYjsAttribute } from '../src/core/attributes' + +describe('@slate/yjs attribute contract', () => { + it('writes non-string Yjs attributes through the interop boundary', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const text = new Y.XmlText() + + root.insert(0, [text]) + setYjsAttribute(text, 'bold', true) + setYjsAttribute(text, 'level', 2) + + assert.deepEqual(getYjsAttributes(text), { + bold: true, + level: 2, + }) + }) +}) diff --git a/packages/slate-yjs/test/awareness-contract.spec.ts b/packages/slate-yjs/test/awareness-contract.spec.ts index e0cde132fa..d315f92d79 100644 --- a/packages/slate-yjs/test/awareness-contract.spec.ts +++ b/packages/slate-yjs/test/awareness-contract.spec.ts @@ -3,29 +3,40 @@ import { describe, it } from 'node:test' import type { Descendant, Range } from 'slate' import { + clearYjsTrace, + connectYjsPeer, createYjsPeer, + disconnectYjsPeer, FakeAwareness, - getYjsState, + getYjsAwarenessRevision, + getYjsRemoteCursors, + getYjsTrace, + type Peer, + paragraph, runYjsUpdate, + subscribeYjsAwareness, } from './support/collaboration' -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type AwarePeer = { + readonly awareness: FakeAwareness + readonly peer: Peer +} -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), ] -const selection = (path = [0, 0], offset = 2): Range => ({ +const selection = ( + path: Range['anchor']['path'] = [0, 0], + offset = 2 +): Range => ({ anchor: { path, offset }, focus: { path, offset }, }) -const createAwarePeer = () => { +const createAwarePeer = (): AwarePeer => { const awareness = new FakeAwareness(2) const peer = createYjsPeer({ awareness, @@ -38,11 +49,11 @@ const createAwarePeer = () => { } const sendRemoteSelection = ( - peer: ReturnType['peer'], + peer: Peer, awareness: FakeAwareness, range: Range, clientId = 101 -) => { +): void => { runYjsUpdate(peer, (yjs) => { yjs.sendSelection(range) awareness.setRemoteState(clientId, { @@ -63,8 +74,8 @@ describe('@slate/yjs awareness contract', () => { }) assert.deepEqual(awareness.getLocalState()?.data, { name: 'B' }) - assert.deepEqual(getYjsState(peer).trace(), []) - assert.deepEqual(getYjsState(peer).remoteCursors(), []) + assert.deepEqual(getYjsTrace(peer), []) + assert.deepEqual(getYjsRemoteCursors(peer), []) }) it('projects remote awareness selections to Slate ranges', () => { @@ -73,7 +84,7 @@ describe('@slate/yjs awareness contract', () => { sendRemoteSelection(peer, awareness, range) - assert.deepEqual(getYjsState(peer).remoteCursors(), [ + assert.deepEqual(getYjsRemoteCursors(peer), [ { clientId: 101, data: { name: 'Ada' }, @@ -82,11 +93,33 @@ describe('@slate/yjs awareness contract', () => { ]) }) + it('ignores non-record remote cursor data', () => { + const { awareness, peer } = createAwarePeer() + const range = selection([1, 0], 3) + + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range) + awareness.setRemoteState(101, { + data: null, + selection: awareness.getLocalState()?.selection, + }) + awareness.setRemoteState(102, { + data: ['Ada'], + selection: awareness.getLocalState()?.selection, + }) + }) + + assert.deepEqual(getYjsRemoteCursors(peer), [ + { clientId: 101, selection: range }, + { clientId: 102, selection: range }, + ]) + }) + it('auto-publishes local selection commits without document operations', () => { const { awareness, peer } = createAwarePeer() const range = selection([0, 0], 1) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) peer.editor.update((tx) => { tx.selection.set(range) }) @@ -94,36 +127,36 @@ describe('@slate/yjs awareness contract', () => { selection: awareness.getLocalState()?.selection, }) - assert.deepEqual(getYjsState(peer).trace(), []) - assert.deepEqual(getYjsState(peer).remoteCursors()[0]?.selection, range) + assert.deepEqual(getYjsTrace(peer), []) + assert.deepEqual(getYjsRemoteCursors(peer)[0]?.selection, range) }) it('does not expose remote cursors while disconnected', () => { const { awareness, peer } = createAwarePeer() sendRemoteSelection(peer, awareness, selection()) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) + disconnectYjsPeer(peer) - assert.deepEqual(getYjsState(peer).remoteCursors(), []) + assert.deepEqual(getYjsRemoteCursors(peer), []) - runYjsUpdate(peer, (yjs) => yjs.connect()) + connectYjsPeer(peer) - assert.equal(getYjsState(peer).remoteCursors().length, 1) + assert.equal(getYjsRemoteCursors(peer).length, 1) }) it('increments awareness revision on remote changes', () => { const { awareness, peer } = createAwarePeer() - const before = getYjsState(peer).awarenessRevision() + const before = getYjsAwarenessRevision(peer) sendRemoteSelection(peer, awareness, selection()) - assert.equal(getYjsState(peer).awarenessRevision() > before, true) + assert.equal(getYjsAwarenessRevision(peer) > before, true) }) it('notifies awareness subscribers on remote changes', () => { const { awareness, peer } = createAwarePeer() let notifications = 0 - const unsubscribe = getYjsState(peer).subscribeAwareness(() => { + const unsubscribe = subscribeYjsAwareness(peer, () => { notifications += 1 }) @@ -143,7 +176,7 @@ describe('@slate/yjs awareness contract', () => { tx.nodes.move({ at: [0], to: [2] }) }) - assert.deepEqual(getYjsState(peer).remoteCursors()[0]?.selection, { + assert.deepEqual(getYjsRemoteCursors(peer)[0]?.selection, { anchor: { path: [2, 0], offset: 2 }, focus: { path: [2, 0], offset: 2 }, }) diff --git a/packages/slate-yjs/test/delete-fragment-contract.spec.ts b/packages/slate-yjs/test/delete-fragment-contract.spec.ts index a052c6dad6..0f5aabdec2 100644 --- a/packages/slate-yjs/test/delete-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/delete-fragment-contract.spec.ts @@ -1,18 +1,29 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createEditor, type Descendant, type Operation } from 'slate' +import { + createEditor, + type Descendant, + type Operation, + type Range, +} from 'slate' import { Editor } from 'slate/internal' import { - assertNoRootSnapshot, assertPeerTexts, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getVisibleYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + recordEditorOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -21,25 +32,22 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), ] -const createPeer = (clientId: keyof typeof clientIds) => +const createPeer = (clientId: ClientId): Peer => createYjsPeer({ children: initialValue(), clientId, numericClientId: clientIds[clientId], }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, @@ -47,10 +55,9 @@ const createPeers = (ids: Array) => }) const collectDeleteFragmentOperations = ( - selection: NonNullable['selection']> -) => { + selection: Range +): Operation['type'][] => { const editor = createEditor() - const operations: Operation[] = [] Editor.replace(editor, { children: initialValue(), @@ -58,11 +65,8 @@ const collectDeleteFragmentOperations = ( selection: null, }) - editor.extend({ + const operations = recordEditorOperationTypes(editor, { name: 'delete-fragment-operation-capture', - onCommit({ commit }) { - operations.push(...commit.operations) - }, }) editor.update((tx) => { @@ -75,13 +79,10 @@ const collectDeleteFragmentOperations = ( tx.fragment.delete() }) - return operations.map((operation) => operation.type) + return operations } -const selectAndDeleteFragment = ( - peer: ReturnType, - selection: NonNullable['selection']> -) => { +const selectAndDeleteFragment = (peer: Peer, selection: Range): void => { peer.editor.update((tx) => { tx.selection.set(selection) }) @@ -91,21 +92,21 @@ const selectAndDeleteFragment = ( }) } -const deleteBetaMiddle = (peer: ReturnType) => { +const deleteBetaMiddle = (peer: Peer): void => { selectAndDeleteFragment(peer, { anchor: { path: [1, 0], offset: 1 }, focus: { path: [1, 0], offset: 3 }, }) } -const deleteFromAlphaIntoGamma = (peer: ReturnType) => { +const deleteFromAlphaIntoGamma = (peer: Peer): void => { selectAndDeleteFragment(peer, { anchor: { path: [0, 0], offset: 2 }, focus: { path: [2, 0], offset: 2 }, }) } -const appendRemoteGamma = (peer: ReturnType) => { +const appendRemoteGamma = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [2, 0], offset: 'gamma'.length } }) }) @@ -143,57 +144,49 @@ describe('@slate/yjs delete_fragment collaboration contract', () => { const peer = createPeer('b') const text = getVisibleYjsNodeAt(peer, [1, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) deleteBetaMiddle(peer) - assert.deepEqual(getParagraphTexts(peer), ['alpha', 'ba', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'ba', 'gamma']) assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), text) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'remove_text' }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text inside the end block when an offline deleteFragment reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) deleteFromAlphaIntoGamma(b) appendRemoteGamma(a) syncConnectedPeers(peers) - assert.deepEqual(getParagraphTexts(a), ['alpha', 'beta', 'gamma!']) - assert.deepEqual(getParagraphTexts(b), ['almma']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha', 'beta', 'gamma!']) + assert.deepEqual(getPeerTopLevelTexts(b), ['almma']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['almma!']) - assertNoRootSnapshot(b) }) it('undoes and redoes only the local cross-block deleteFragment intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) deleteFromAlphaIntoGamma(b) appendRemoteGamma(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['almma!']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha', 'beta', 'gamma!']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['almma!']) - assertNoRootSnapshot(b) }) }) diff --git a/packages/slate-yjs/test/document-id-contract.spec.ts b/packages/slate-yjs/test/document-id-contract.spec.ts index b9327340c0..d70cae80f2 100644 --- a/packages/slate-yjs/test/document-id-contract.spec.ts +++ b/packages/slate-yjs/test/document-id-contract.spec.ts @@ -2,7 +2,44 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import * as Y from 'yjs' +import { + createYjsNode, + getYjsNode, + getYjsParent, + readSlateValueFromYjs, +} from '../src/core/document' + describe('@slate/yjs document id contract', () => { + it('resolves empty paths to the Yjs root', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const node = createYjsNode({ children: [{ text: 'plain' }] }) + + root.insert(0, [node]) + + assert.equal(getYjsNode(root, []), root) + + const { index, parent } = getYjsParent(root, [0]) + + assert.equal(index, 0) + assert.equal(parent, root) + assert.throws(() => getYjsParent(root, []), /Yjs root/) + }) + + it('uses the fallback element type consistently for typeless elements', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const node = createYjsNode({ children: [{ text: 'plain' }] }) + + root.insert(0, [node]) + + assert.equal(node.getAttribute('slate:type'), 'element') + + assert.deepEqual(readSlateValueFromYjs(root), [ + { children: [{ text: 'plain' }], type: 'element' }, + ]) + }) + it('keeps generated virtual node ids unique across isolated browser bundles', async () => { const nonce = Date.now() const first = await import(`../src/core/document.ts?first=${nonce}`) diff --git a/packages/slate-yjs/test/insert-fragment-contract.spec.ts b/packages/slate-yjs/test/insert-fragment-contract.spec.ts index cf014f8f58..b8e97cacdd 100644 --- a/packages/slate-yjs/test/insert-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/insert-fragment-contract.spec.ts @@ -1,17 +1,23 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type Descendant, defineEditorExtension } from 'slate' +import type { Descendant, Operation } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + recordOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -20,17 +26,11 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [paragraph('alpha')] +const initialValue = (): Descendant[] => [paragraph('alpha')] -const createPeer = ( - clientId: keyof typeof clientIds, - seedUpdate?: Uint8Array -) => +const createPeer = (clientId: ClientId, seedUpdate?: Uint8Array): Peer => createYjsPeer({ children: initialValue(), clientId, @@ -38,14 +38,14 @@ const createPeer = ( seedUpdate, }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const insertFragment = (peer: ReturnType) => { +const insertFragment = (peer: Peer): void => { peer.editor.update((tx) => { tx.selection.set({ anchor: { path: [0, 0], offset: 'alpha'.length }, @@ -57,37 +57,22 @@ const insertFragment = (peer: ReturnType) => { }) } -const appendRemoteText = (peer: ReturnType) => { +const appendRemoteText = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert(' Ada', { at: { path: [0, 0], offset: 'alpha'.length } }) }) } -const collectInsertFragmentOperations = () => { +const collectInsertFragmentOperations = (): Operation['type'][] => { const peer = createPeer('b') - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'insert-fragment-operation-recorder', - setup() { - return { - onCommit({ commit }) { - if ( - commit.command?.type === 'insert_fragment' || - commit.operations.some((operation) => - ['insert_node', 'merge_node'].includes(operation.type) - ) - ) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - } - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'insert-fragment-operation-recorder', + shouldRecord: ({ commit }) => + commit.command?.type === 'insert_fragment' || + commit.operations.some((operation) => + ['insert_node', 'merge_node'].includes(operation.type) + ), + }) insertFragment(peer) return operations @@ -106,13 +91,12 @@ describe('@slate/yjs insert_fragment collaboration contract', () => { const peer = createPeer('b') const text = getYjsNodeAt(peer, [0, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) insertFragment(peer) - assert.deepEqual(getParagraphTexts(peer), ['alphaLin fragment']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alphaLin fragment']) assert.equal(getYjsNodeAt(peer, [0, 0]), text) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'insert_node' }, { fallback: 'text-merge-preserve-yjs-boundary', @@ -120,36 +104,32 @@ describe('@slate/yjs insert_fragment collaboration contract', () => { operationType: 'merge_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline insert_fragment reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) insertFragment(b) appendRemoteText(a) syncConnectedPeers(peers) - assert.deepEqual(getParagraphTexts(a), ['alpha Ada']) - assert.deepEqual(getParagraphTexts(b), ['alphaLin fragment']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha Ada']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alphaLin fragment']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha AdaLin fragment']) - assertNoRootSnapshot(b) }) it('recovers insert_fragment convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) insertFragment(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alphaLin fragment']) }) @@ -162,41 +142,37 @@ describe('@slate/yjs insert_fragment collaboration contract', () => { syncConnectedPeers(peers) assertPeerTexts(peers, ['alphaLin fragment']) - const [text] = getParagraphTexts(b) + const [text] = getPeerTopLevelTexts(b) + assert.equal(typeof text, 'string') b.editor.update((tx) => { tx.selection.set({ - anchor: { path: [0, 0], offset: text!.length }, - focus: { path: [0, 0], offset: text!.length }, + anchor: { path: [0, 0], offset: text.length }, + focus: { path: [0, 0], offset: text.length }, }) tx.text.deleteBackward({ unit: 'character' }) }) syncConnectedPeers(peers) assertPeerTexts(peers, ['alphaLin fragmen']) - assertNoRootSnapshot(b) }) it('undoes and redoes only the local insert_fragment intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) insertFragment(b) appendRemoteText(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha AdaLin fragment']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha Ada']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha AdaLin fragment']) - assertNoRootSnapshot(b) }) }) diff --git a/packages/slate-yjs/test/lift-nodes-contract.spec.ts b/packages/slate-yjs/test/lift-nodes-contract.spec.ts index f55e9fd609..19d53c0c8f 100644 --- a/packages/slate-yjs/test/lift-nodes-contract.spec.ts +++ b/packages/slate-yjs/test/lift-nodes-contract.spec.ts @@ -1,16 +1,22 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type Descendant, defineEditorExtension } from 'slate' -import { Editor } from 'slate/internal' +import type { Descendant, Operation } from 'slate' import { - assertNoRootSnapshot, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getVisibleYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + recordOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -19,33 +25,30 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const section = (...children: Descendant[]): Descendant => ({ +const section = (...children: readonly Descendant[]): Descendant => ({ type: 'section', children, }) -const initialValue = () => [ +const initialValue = (): Descendant[] => [ section(paragraph('alpha'), paragraph('beta')), paragraph('gamma'), ] -const onlyChildValue = () => [section(paragraph('alpha'))] +const onlyChildValue = (): Descendant[] => [section(paragraph('alpha'))] -const tripleChildValue = () => [ +const tripleChildValue = (): Descendant[] => [ section(paragraph('alpha'), paragraph('beta'), paragraph('gamma')), paragraph('delta'), ] const createPeer = ( - clientId: keyof typeof clientIds, + clientId: ClientId, seedUpdate?: Uint8Array, - children: Descendant[] = initialValue() -) => + children: readonly Descendant[] = initialValue() +): Peer => createYjsPeer({ children, clientId, @@ -54,73 +57,53 @@ const createPeer = ( }) const createPeers = ( - ids: Array, - children: Descendant[] = initialValue() -) => + ids: readonly ClientId[], + children: readonly Descendant[] = initialValue() +): Peer[] => createSeededYjsPeers({ children, clientIds: ids, numericClientIds: clientIds, }) -const topLevelTexts = (peer: ReturnType) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) - -const liftFirstNestedBlock = (peer: ReturnType) => { +const liftFirstNestedBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.lift({ at: [0, 0] }) }) } -const liftOnlyNestedBlock = liftFirstNestedBlock - -const liftLastNestedBlock = (peer: ReturnType) => { +const liftLastNestedBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.lift({ at: [0, 1] }) }) } -const liftMiddleNestedBlock = (peer: ReturnType) => { +const liftMiddleNestedBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.lift({ at: [0, 1] }) }) } -const appendNestedAlpha = (peer: ReturnType) => { +const appendNestedAlpha = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0, 0], offset: 'alpha'.length } }) }) } -const appendNestedBeta = (peer: ReturnType) => { +const appendNestedBeta = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 1, 0], offset: 'beta'.length } }) }) } const collectLiftOperations = ( - lift: (peer: ReturnType) => void = liftFirstNestedBlock, - children: Descendant[] = initialValue() -) => { + lift: (peer: Peer) => void = liftFirstNestedBlock, + children: readonly Descendant[] = initialValue() +): Operation['type'][] => { const peer = createPeer('b', undefined, children) - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'lift-operation-recorder', - setup() { - return { - onCommit({ commit }) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'lift-operation-recorder', + }) lift(peer) return operations @@ -133,7 +116,7 @@ describe('@slate/yjs liftNodes collaboration contract', () => { it('characterizes only-child public liftNodes as move_node then remove_node', () => { assert.deepEqual( - collectLiftOperations(liftOnlyNestedBlock, onlyChildValue()), + collectLiftOperations(liftFirstNestedBlock, onlyChildValue()), ['move_node', 'remove_node'] ) }) @@ -149,54 +132,49 @@ describe('@slate/yjs liftNodes collaboration contract', () => { const peer = createPeer('b') const original = getVisibleYjsNodeAt(peer, [0, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) liftFirstNestedBlock(peer) - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta', 'gamma']) assert.equal(getVisibleYjsNodeAt(peer, [0]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-move-placeholder', mode: 'traceable-fallback', operationType: 'move_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline first-child lift reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftFirstNestedBlock(b) appendNestedAlpha(a) syncConnectedPeers(peers) - assert.deepEqual(topLevelTexts(a), ['alpha!beta', 'gamma']) - assert.deepEqual(topLevelTexts(b), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!', 'beta', 'gamma']) } - assertNoRootSnapshot(b) }) it('recovers first-child lift convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftFirstNestedBlock(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta', 'gamma']) } }) @@ -204,42 +182,37 @@ describe('@slate/yjs liftNodes collaboration contract', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftFirstNestedBlock(b) appendNestedAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!', 'beta', 'gamma']) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!beta', 'gamma']) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!', 'beta', 'gamma']) } - assertNoRootSnapshot(b) }) it('applies local offline only-child lift without replacing the original Yjs node', () => { const peer = createPeer('b', undefined, onlyChildValue()) const original = getVisibleYjsNodeAt(peer, [0, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) - liftOnlyNestedBlock(peer) + disconnectAndClearYjsTrace(peer) + liftFirstNestedBlock(peer) - assert.deepEqual(topLevelTexts(peer), ['alpha']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha']) assert.equal(getVisibleYjsNodeAt(peer, [0]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-move-placeholder', mode: 'traceable-fallback', @@ -251,41 +224,37 @@ describe('@slate/yjs liftNodes collaboration contract', () => { operationType: 'remove_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline only-child lift reconnects', () => { const peers = createPeers(['a', 'b', 'c'], onlyChildValue()) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) - liftOnlyNestedBlock(b) + disconnectYjsPeer(b) + liftFirstNestedBlock(b) appendNestedAlpha(a) syncConnectedPeers(peers) - assert.deepEqual(topLevelTexts(a), ['alpha!']) - assert.deepEqual(topLevelTexts(b), ['alpha']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!']) } - assertNoRootSnapshot(b) }) it('recovers only-child lift convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c'], onlyChildValue()) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) - liftOnlyNestedBlock(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + disconnectYjsPeer(b) + liftFirstNestedBlock(b) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha']) } }) @@ -293,83 +262,74 @@ describe('@slate/yjs liftNodes collaboration contract', () => { const peers = createPeers(['a', 'b', 'c'], onlyChildValue()) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) - liftOnlyNestedBlock(b) + disconnectYjsPeer(b) + liftFirstNestedBlock(b) appendNestedAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!']) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!']) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!']) } - assertNoRootSnapshot(b) }) it('applies local offline last-child lift without replacing the original Yjs node', () => { const peer = createPeer('b') const original = getVisibleYjsNodeAt(peer, [0, 1]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) liftLastNestedBlock(peer) - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta', 'gamma']) assert.equal(getVisibleYjsNodeAt(peer, [1]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-move-placeholder', mode: 'traceable-fallback', operationType: 'move_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline last-child lift reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftLastNestedBlock(b) appendNestedBeta(a) syncConnectedPeers(peers) - assert.deepEqual(topLevelTexts(a), ['alphabeta!', 'gamma']) - assert.deepEqual(topLevelTexts(b), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alphabeta!', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta!', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta!', 'gamma']) } - assertNoRootSnapshot(b) }) it('recovers last-child lift convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftLastNestedBlock(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta', 'gamma']) } }) @@ -377,42 +337,42 @@ describe('@slate/yjs liftNodes collaboration contract', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftLastNestedBlock(b) appendNestedBeta(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta!', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta!', 'gamma']) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alphabeta!', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alphabeta!', 'gamma']) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta!', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta!', 'gamma']) } - assertNoRootSnapshot(b) }) it('applies local offline middle-child lift through split_node and move_node', () => { const peer = createPeer('b', undefined, tripleChildValue()) const original = getVisibleYjsNodeAt(peer, [0, 1]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) liftMiddleNestedBlock(peer) - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma', 'delta']) + assert.deepEqual(getPeerTopLevelTexts(peer), [ + 'alpha', + 'beta', + 'gamma', + 'delta', + ]) assert.equal(getVisibleYjsNodeAt(peer, [1]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'split_node' }, { fallback: 'virtual-move-placeholder', @@ -420,46 +380,52 @@ describe('@slate/yjs liftNodes collaboration contract', () => { operationType: 'move_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline middle-child lift reconnects', () => { const peers = createPeers(['a', 'b', 'c'], tripleChildValue()) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftMiddleNestedBlock(b) appendNestedBeta(a) syncConnectedPeers(peers) - assert.deepEqual(topLevelTexts(a), ['alphabeta!gamma', 'delta']) - assert.deepEqual(topLevelTexts(b), ['alpha', 'beta', 'gamma', 'delta']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alphabeta!gamma', 'delta']) + assert.deepEqual(getPeerTopLevelTexts(b), [ + 'alpha', + 'beta', + 'gamma', + 'delta', + ]) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), [ + assert.deepEqual(getPeerTopLevelTexts(peer), [ 'alpha', 'beta!', 'gamma', 'delta', ]) } - assertNoRootSnapshot(b) }) it('recovers middle-child lift convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c'], tripleChildValue()) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftMiddleNestedBlock(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha', 'beta', 'gamma', 'delta']) + assert.deepEqual(getPeerTopLevelTexts(peer), [ + 'alpha', + 'beta', + 'gamma', + 'delta', + ]) } }) @@ -467,15 +433,14 @@ describe('@slate/yjs liftNodes collaboration contract', () => { const peers = createPeers(['a', 'b', 'c'], tripleChildValue()) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) liftMiddleNestedBlock(b) appendNestedBeta(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), [ + assert.deepEqual(getPeerTopLevelTexts(peer), [ 'alpha', 'beta!', 'gamma', @@ -483,22 +448,19 @@ describe('@slate/yjs liftNodes collaboration contract', () => { ]) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alphabeta!gamma', 'delta']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alphabeta!gamma', 'delta']) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), [ + assert.deepEqual(getPeerTopLevelTexts(peer), [ 'alpha', 'beta!', 'gamma', 'delta', ]) } - assertNoRootSnapshot(b) }) }) diff --git a/packages/slate-yjs/test/merge-node-contract.spec.ts b/packages/slate-yjs/test/merge-node-contract.spec.ts index a463d8ad19..64a86b3472 100644 --- a/packages/slate-yjs/test/merge-node-contract.spec.ts +++ b/packages/slate-yjs/test/merge-node-contract.spec.ts @@ -1,33 +1,34 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Descendant } from 'slate' -import { Editor } from 'slate/internal' -import { readSlateValueFromYjs } from '../src/core/document' import { - assertNoRootSnapshot, assertPeerTexts, + clearYjsTrace, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getYjsNodeAt, - getYjsState, + getYjsTrace, type Peer, - runYjsUpdate, + paragraph, + readPeerChildren, + readPeerSlateValue, + reconcileYjsPeer, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) - -const quote = (...children: Descendant[]): Descendant => ({ +const quote = (...children: readonly Descendant[]): Descendant => ({ type: 'block-quote', children, }) -const initialValue = () => [paragraph('alpha'), paragraph('beta')] +const initialValue = (): Descendant[] => [paragraph('alpha'), paragraph('beta')] const incompatibleMergeValue = (): Descendant[] => [ paragraph('block 2'), @@ -44,30 +45,23 @@ const textMergeValue = (): Descendant[] => [ const createPeer = ( clientId: string, seedUpdate?: Uint8Array, - children: Descendant[] = initialValue() + children: readonly Descendant[] = initialValue() ): Peer => createYjsPeer({ children, clientId, seedUpdate }) const createPeers = ( - clientIds: string[], - children: Descendant[] = initialValue() -) => { + clientIds: readonly string[], + children: readonly Descendant[] = initialValue() +): Peer[] => { return createSeededYjsPeers({ children, clientIds }) } -const yjsState = getYjsState -const yjsUpdate = runYjsUpdate -const paragraphTexts = getParagraphTexts -const yjsNodeAt = getYjsNodeAt -const syncConnected = syncConnectedPeers -const assertAllTexts = assertPeerTexts - -const mergeSecondParagraph = (peer: Peer) => { +const mergeSecondParagraph = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.merge({ at: [1] }) }) } -const mergeRightText = (peer: Peer) => { +const mergeRightText = (peer: Peer): void => { peer.editor.update((tx) => { tx.operations.replay([ { @@ -80,7 +74,7 @@ const mergeRightText = (peer: Peer) => { }) } -const appendRemoteTextToLeftParagraph = (peer: Peer) => { +const appendRemoteTextToLeftParagraph = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) @@ -90,14 +84,14 @@ describe('@slate/yjs merge_node collaboration contract', () => { it('elides incompatible structural merge instead of nesting blocks into a paragraph', () => { const peer = createPeer('b', undefined, incompatibleMergeValue()) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) mergeSecondParagraph(peer) - assert.deepEqual(readSlateValueFromYjs(yjsState(peer).root()), [ + assert.deepEqual(readPeerSlateValue(peer), [ paragraph('block 2'), quote(paragraph('alpha'), paragraph('beta')), ]) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'incompatible-structural-merge-elided', mode: 'traceable-fallback', @@ -105,106 +99,92 @@ describe('@slate/yjs merge_node collaboration contract', () => { }, ]) - yjsUpdate(peer, (yjs) => yjs.reconcile()) + reconcileYjsPeer(peer) - assert.deepEqual( - Editor.getSnapshot(peer.editor).children, - incompatibleMergeValue() - ) - assertNoRootSnapshot(peer) + assert.deepEqual(readPeerChildren(peer), incompatibleMergeValue()) }) it('applies local offline public merge without a root snapshot fallback', () => { const peer = createPeer('b') - const survivor = yjsNodeAt(peer, [0]) + const survivor = getYjsNodeAt(peer, [0]) - yjsUpdate(peer, (yjs) => yjs.disconnect()) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) mergeSecondParagraph(peer) - assert.deepEqual(paragraphTexts(peer), ['alphabeta']) - assert.equal(yjsNodeAt(peer, [0]), survivor) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(peer), ['alphabeta']) + assert.equal(getYjsNodeAt(peer, [0]), survivor) + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-merge-ref', mode: 'traceable-fallback', operationType: 'merge_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote survivor edits when an offline merge reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) mergeSecondParagraph(b) appendRemoteTextToLeftParagraph(a) - syncConnected(peers) + syncConnectedPeers(peers) - assert.deepEqual(paragraphTexts(a), ['alpha!', 'beta']) - assert.deepEqual(paragraphTexts(b), ['alphabeta']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!', 'beta']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alphabeta']) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alpha!beta']) - assertNoRootSnapshot(b) + assertPeerTexts(peers, ['alpha!beta']) }) it('recovers merge convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) mergeSecondParagraph(b) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alphabeta']) + assertPeerTexts(peers, ['alphabeta']) }) it('undoes and redoes only the local merge intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) mergeSecondParagraph(b) appendRemoteTextToLeftParagraph(a) - syncConnected(peers) + syncConnectedPeers(peers) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!beta']) + connectYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!beta']) - yjsUpdate(b, (yjs) => yjs.undo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!', 'beta']) + undoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!', 'beta']) - yjsUpdate(b, (yjs) => yjs.redo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!beta']) - assertNoRootSnapshot(b) + redoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!beta']) }) it('keeps raw text merge_node in a traceable identity-preserving fallback', () => { const peers = createPeers(['a', 'b', 'c'], textMergeValue()) const [a, b] = peers - const survivor = yjsNodeAt(b, [0, 0]) - const rightText = yjsNodeAt(b, [0, 1]) + const survivor = getYjsNodeAt(b, [0, 0]) + const rightText = getYjsNodeAt(b, [0, 1]) - yjsUpdate(b, (yjs) => yjs.disconnect()) - yjsUpdate(b, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(b) mergeRightText(b) appendRemoteTextToLeftParagraph(a) - syncConnected(peers) + syncConnectedPeers(peers) - assert.deepEqual(paragraphTexts(a), ['alpha!beta']) - assert.deepEqual(paragraphTexts(b), ['alphabeta']) - assert.equal(yjsNodeAt(b, [0, 0]), survivor) - assert.equal(yjsNodeAt(b, [0, 1]), rightText) - assert.deepEqual(yjsState(b).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!beta']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alphabeta']) + assert.equal(getYjsNodeAt(b, [0, 0]), survivor) + assert.equal(getYjsNodeAt(b, [0, 1]), rightText) + assert.deepEqual(getYjsTrace(b), [ { fallback: 'text-merge-preserve-yjs-boundary', mode: 'traceable-fallback', @@ -212,17 +192,13 @@ describe('@slate/yjs merge_node collaboration contract', () => { }, ]) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!beta']) + connectYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!beta']) - yjsUpdate(b, (yjs) => yjs.undo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!beta']) + undoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!beta']) - yjsUpdate(b, (yjs) => yjs.redo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!beta']) - assertNoRootSnapshot(b) + redoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!beta']) }) }) diff --git a/packages/slate-yjs/test/move-node-contract.spec.ts b/packages/slate-yjs/test/move-node-contract.spec.ts index 6dde074d53..b117512aaf 100644 --- a/packages/slate-yjs/test/move-node-contract.spec.ts +++ b/packages/slate-yjs/test/move-node-contract.spec.ts @@ -1,16 +1,24 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type Descendant, defineEditorExtension } from 'slate' +import type { Descendant, Operation } from 'slate' import { Editor } from 'slate/internal' import { - assertNoRootSnapshot, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getVisibleYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + readPeerChildren, + recordOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -19,32 +27,29 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const section = (...children: Descendant[]): Descendant => ({ +const section = (...children: readonly Descendant[]): Descendant => ({ type: 'section', children, }) -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), ] -const nestedInitialValue = () => [ +const nestedInitialValue = (): Descendant[] => [ section(paragraph('alpha'), paragraph('beta')), section(paragraph('gamma')), ] const createPeer = ( - clientId: keyof typeof clientIds, + clientId: ClientId, seedUpdate?: Uint8Array, - children: Descendant[] = initialValue() -) => + children: readonly Descendant[] = initialValue() +): Peer => createYjsPeer({ children, clientId, @@ -52,27 +57,22 @@ const createPeer = ( seedUpdate, }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const createNestedPeers = (ids: Array) => +const createNestedPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: nestedInitialValue(), clientIds: ids, numericClientIds: clientIds, }) -const topLevelTexts = (peer: ReturnType) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) - -const nestedTexts = (peer: ReturnType) => - Editor.getSnapshot(peer.editor).children.map((node, index) => +const nestedTexts = (peer: Peer): string[][] => + readPeerChildren(peer).map((node, index) => 'children' in node ? node.children.map((_, childIndex) => Editor.string(peer.editor, [index, childIndex]) @@ -80,50 +80,35 @@ const nestedTexts = (peer: ReturnType) => : [] ) -const moveFirstBlockToEnd = (peer: ReturnType) => { +const moveFirstBlockToEnd = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.move({ at: [0], to: [2] }) }) } -const moveNestedBlockToSecondSection = ( - peer: ReturnType -) => { +const moveNestedBlockToSecondSection = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.move({ at: [0, 0], to: [1, 1] }) }) } -const appendRemoteAlpha = (peer: ReturnType) => { +const appendRemoteAlpha = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) } -const appendNestedRemoteAlpha = (peer: ReturnType) => { +const appendNestedRemoteAlpha = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0, 0], offset: 'alpha'.length } }) }) } -const collectMoveOperations = () => { +const collectMoveOperations = (): Operation['type'][] => { const peer = createPeer('b') - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'move-operation-recorder', - setup() { - return { - onCommit({ commit }) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'move-operation-recorder', + }) moveFirstBlockToEnd(peer) return operations @@ -138,97 +123,87 @@ describe('@slate/yjs move_node collaboration contract', () => { const peer = createPeer('b') const original = getVisibleYjsNodeAt(peer, [0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) moveFirstBlockToEnd(peer) - assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['beta', 'gamma', 'alpha']) assert.equal(getVisibleYjsNodeAt(peer, [2]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-move-placeholder', mode: 'traceable-fallback', operationType: 'move_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline same-parent move reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) moveFirstBlockToEnd(b) appendRemoteAlpha(a) syncConnectedPeers(peers) - assert.deepEqual(topLevelTexts(a), ['alpha!', 'beta', 'gamma']) - assert.deepEqual(topLevelTexts(b), ['beta', 'gamma', 'alpha']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(b), ['beta', 'gamma', 'alpha']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['beta', 'gamma', 'alpha!']) } - assertNoRootSnapshot(b) }) it('undoes and redoes only the local same-parent move intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) moveFirstBlockToEnd(b) appendRemoteAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['beta', 'gamma', 'alpha!']) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!', 'beta', 'gamma']) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(topLevelTexts(peer), ['beta', 'gamma', 'alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['beta', 'gamma', 'alpha!']) } - assertNoRootSnapshot(b) }) it('applies local offline cross-parent move without replacing the original Yjs node', () => { const peer = createPeer('b', undefined, nestedInitialValue()) const original = getVisibleYjsNodeAt(peer, [0, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) moveNestedBlockToSecondSection(peer) assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha']]) assert.equal(getVisibleYjsNodeAt(peer, [1, 1]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-move-placeholder', mode: 'traceable-fallback', operationType: 'move_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline cross-parent move reconnects', () => { const peers = createNestedPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) moveNestedBlockToSecondSection(b) appendNestedRemoteAlpha(a) syncConnectedPeers(peers) @@ -236,41 +211,35 @@ describe('@slate/yjs move_node collaboration contract', () => { assert.deepEqual(nestedTexts(a), [['alpha!', 'beta'], ['gamma']]) assert.deepEqual(nestedTexts(b), [['beta'], ['gamma', 'alpha']]) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha!']]) } - assertNoRootSnapshot(b) }) it('undoes and redoes only the local cross-parent move intent after reconnect', () => { const peers = createNestedPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) moveNestedBlockToSecondSection(b) appendNestedRemoteAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha!']]) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { assert.deepEqual(nestedTexts(peer), [['alpha!', 'beta'], ['gamma']]) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { assert.deepEqual(nestedTexts(peer), [['beta'], ['gamma', 'alpha!']]) } - assertNoRootSnapshot(b) }) }) diff --git a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts index a4195c4650..8e3fe058a1 100644 --- a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts +++ b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts @@ -5,6 +5,10 @@ import * as Y from 'yjs' import { applySlateOperationToYjs } from '../src/core/operations' +// The encoder still needs a runtime guard for operation types newer than this package. +const futureSlateOperation = (type: string): Operation => + ({ type }) as unknown as Operation + describe('@slate/yjs operation encoder exhaustiveness contract', () => { it('treats selection operations as document-content no-ops', () => { const doc = new Y.Doc() @@ -24,9 +28,7 @@ describe('@slate/yjs operation encoder exhaustiveness contract', () => { it('rejects a future Slate operation instead of silently skipping it', () => { const doc = new Y.Doc() const root = doc.get('slate', Y.XmlElement) - const operation = { - type: 'future_operation', - } as unknown as Operation + const operation = futureSlateOperation('future_operation') assert.throws( () => applySlateOperationToYjs(root, operation), diff --git a/packages/slate-yjs/test/package-config-contract.spec.ts b/packages/slate-yjs/test/package-config-contract.spec.ts index f594e58f83..8f778194cc 100644 --- a/packages/slate-yjs/test/package-config-contract.spec.ts +++ b/packages/slate-yjs/test/package-config-contract.spec.ts @@ -1,32 +1,175 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { describe, it } from 'node:test' +import { isRecord } from '../src/core/record' +import { SUPPORTED_YJS_UNDO_MANAGER_VERSION } from '../src/core/undo-manager-adapter' -const readJson = (path: string) => - JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Record< - string, - any - > +type JsonRecord = Readonly> + +type DependencyMap = Readonly> + +type PackageExport = { + readonly default?: string + readonly import?: string + readonly types?: string +} + +type PackageExports = Readonly> + +type PackageJson = { + readonly dependencies?: DependencyMap + readonly devDependencies?: DependencyMap + readonly exports?: PackageExports + readonly optionalDependencies?: DependencyMap + readonly peerDependencies?: DependencyMap +} + +type TsConfigJson = { + readonly compilerOptions?: { + readonly paths?: JsonRecord + } +} + +const readJsonRecord = (path: string): JsonRecord => { + const value = JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) + + if (!isRecord(value)) { + throw new Error(`${path} must contain a JSON object.`) + } + + return value +} + +const readOptionalRecord = ( + record: JsonRecord, + key: string +): JsonRecord | undefined => { + const value = record[key] + + if (value === undefined) { + return undefined + } + + if (!isRecord(value)) { + throw new Error(`${key} must be a JSON object.`) + } + + return value +} + +const readOptionalString = ( + record: JsonRecord, + key: string +): string | undefined => { + const value = record[key] + + if (value === undefined || typeof value === 'string') { + return value + } + + throw new Error(`${key} must be a string.`) +} + +const readDependencyMap = ( + record: JsonRecord, + key: string +): DependencyMap | undefined => { + const dependencies = readOptionalRecord(record, key) + + if (dependencies === undefined) { + return undefined + } + + const map: Record = {} + + for (const [name, version] of Object.entries(dependencies)) { + if (typeof version !== 'string') { + throw new Error(`${key}.${name} must be a string.`) + } + + map[name] = version + } + + return map +} + +const readPackageExports = (record: JsonRecord): PackageExports | undefined => { + const rawExports = readOptionalRecord(record, 'exports') + + if (rawExports === undefined) { + return undefined + } + + const exports: Record = {} + + for (const [key, value] of Object.entries(rawExports)) { + if (!isRecord(value)) { + throw new Error(`exports.${key} must be a JSON object.`) + } + + exports[key] = { + default: readOptionalString(value, 'default'), + import: readOptionalString(value, 'import'), + types: readOptionalString(value, 'types'), + } + } + + return exports +} + +const readPackageJson = (path: string): PackageJson => { + const record = readJsonRecord(path) + + return { + dependencies: readDependencyMap(record, 'dependencies'), + devDependencies: readDependencyMap(record, 'devDependencies'), + exports: readPackageExports(record), + optionalDependencies: readDependencyMap(record, 'optionalDependencies'), + peerDependencies: readDependencyMap(record, 'peerDependencies'), + } +} + +const readTsConfigJson = (path: string): TsConfigJson => { + const record = readJsonRecord(path) + const compilerOptions = readOptionalRecord(record, 'compilerOptions') + const paths = + compilerOptions === undefined + ? undefined + : readOptionalRecord(compilerOptions, 'paths') + + return { + compilerOptions: compilerOptions === undefined ? undefined : { paths }, + } +} describe('@slate/yjs package config contract', () => { it('pins Yjs to the audited UndoManager stack contract version', () => { - const rootPackage = readJson('../../../package.json') - const yjsPackage = readJson('../package.json') + const rootPackage = readPackageJson('../../../package.json') + const yjsPackage = readPackageJson('../package.json') - assert.equal(rootPackage.devDependencies?.yjs, '13.6.30') - assert.equal(yjsPackage.dependencies?.yjs, '13.6.30') - assert.equal(yjsPackage.peerDependencies?.yjs, '13.6.30') + assert.equal( + rootPackage.devDependencies?.yjs, + SUPPORTED_YJS_UNDO_MANAGER_VERSION + ) + assert.equal( + yjsPackage.dependencies?.yjs, + SUPPORTED_YJS_UNDO_MANAGER_VERSION + ) + assert.equal( + yjsPackage.peerDependencies?.yjs, + SUPPORTED_YJS_UNDO_MANAGER_VERSION + ) }) it('does not resolve site Yjs imports through package-local node_modules', () => { - const tsconfig = readJson('../../../site/tsconfig.json') + const tsconfig = readTsConfigJson('../../../site/tsconfig.json') const yjsAlias = tsconfig.compilerOptions?.paths?.yjs assert.equal(yjsAlias, undefined) }) it('keeps provider integrations supplied by applications', () => { - const yjsPackage = readJson('../package.json') + const yjsPackage = readPackageJson('../package.json') const sections = [ yjsPackage.dependencies, yjsPackage.devDependencies, @@ -39,4 +182,35 @@ describe('@slate/yjs package config contract', () => { assert.equal(dependencies?.['y-websocket'], undefined) } }) + + it('keeps package exports aligned with built entrypoints', () => { + const yjsPackage = readPackageJson('../package.json') + + assert.deepEqual(Object.keys(yjsPackage.exports ?? {}).sort(), [ + '.', + './core', + './internal', + './react', + ]) + assert.deepEqual(yjsPackage.exports?.['.'], { + default: './dist/index.js', + import: './dist/index.js', + types: './dist/index.d.ts', + }) + assert.deepEqual(yjsPackage.exports?.['./core'], { + default: './dist/core/index.js', + import: './dist/core/index.js', + types: './dist/core/index.d.ts', + }) + assert.deepEqual(yjsPackage.exports?.['./internal'], { + default: './dist/internal/index.js', + import: './dist/internal/index.js', + types: './dist/internal/index.d.ts', + }) + assert.deepEqual(yjsPackage.exports?.['./react'], { + default: './dist/react/index.js', + import: './dist/react/index.js', + types: './dist/react/index.d.ts', + }) + }) }) diff --git a/packages/slate-yjs/test/provider-contract.spec.ts b/packages/slate-yjs/test/provider-contract.spec.ts index 176933dfca..1064711e35 100644 --- a/packages/slate-yjs/test/provider-contract.spec.ts +++ b/packages/slate-yjs/test/provider-contract.spec.ts @@ -1,139 +1,52 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createEditor, type Descendant, type Range } from 'slate' +import { + createEditor, + type Descendant, + type Range, + type Editor as SlateEditor, +} from 'slate' import { Editor } from 'slate/internal' import { history } from 'slate-history' import * as Y from 'yjs' import { createYjsExtension } from '../src' -import type { - YjsExtensionOptions, - YjsProviderEvent, - YjsProviderEventHandler, - YjsProviderLike, - YjsProviderStatus, - YjsProviderStatusPayload, - YjsProviderSyncedPayload, -} from '../src/core/types' +import { + connectedFromYjsProviderStatus, + normalizeYjsProviderStatus, + normalizeYjsProviderSynced, +} from '../src/core/provider' +import type { YjsExtensionOptions, YjsProviderStatus } from '../src/core/types' import { createYjsPeer, - FakeAwareness, - getYjsState, + FakeProvider, + getHistoryUndoCount, + getYjsProviderStatus, + getYjsProviderSynced, + isYjsPeerConnected, + paragraph, + readEditorYjsState, + runEditorYjsUpdate, runYjsUpdate, + undoEditorHistory, } from './support/collaboration' -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type Cleanup = () => void -const initialValue = () => [paragraph('alpha'), paragraph('beta')] +type ProviderEditor = { + readonly cleanup: Cleanup + readonly editor: SlateEditor +} + +const initialValue = (): Descendant[] => [paragraph('alpha'), paragraph('beta')] const selection = (): Range => ({ anchor: { path: [0, 0], offset: 1 }, focus: { path: [0, 0], offset: 3 }, }) -class FakeProvider implements YjsProviderLike { - readonly awareness = new FakeAwareness(12) - readonly doc = new Y.Doc() - - readonly calls: string[] = [] - - status: YjsProviderStatus = 'disconnected' - synced = false - - private readonly statusListeners = new Set< - (status: YjsProviderStatusPayload) => void - >() - private readonly syncedListeners = new Set< - (synced: YjsProviderSyncedPayload) => void - >() - private readonly syncListeners = new Set< - (synced: YjsProviderSyncedPayload) => void - >() - - connect() { - this.calls.push('connect') - this.emitStatus('connected') - } - - destroy() { - this.calls.push('destroy') - } - - disconnect() { - this.calls.push('disconnect') - this.emitStatus('disconnected') - } - - emitStatus(status: YjsProviderStatusPayload) { - this.status = typeof status === 'string' ? status : status.status - - for (const listener of this.statusListeners) { - listener(status) - } - } - - emitSynced(synced: boolean) { - this.synced = synced - - for (const listener of this.syncedListeners) { - listener(synced) - } - } - - emitSyncedState(synced: boolean) { - this.synced = synced - - for (const listener of this.syncedListeners) { - listener({ state: synced }) - } - } - - emitSync(synced: boolean) { - this.synced = synced - - for (const listener of this.syncListeners) { - listener(synced) - } - } - - off(event: YjsProviderEvent, handler: YjsProviderEventHandler) { - if (event === 'status') { - this.statusListeners.delete( - handler as (status: YjsProviderStatusPayload) => void - ) - } else if (event === 'sync') { - this.syncListeners.delete( - handler as (synced: YjsProviderSyncedPayload) => void - ) - } else { - this.syncedListeners.delete( - handler as (synced: YjsProviderSyncedPayload) => void - ) - } - } - - on(event: YjsProviderEvent, handler: YjsProviderEventHandler) { - if (event === 'status') { - this.statusListeners.add( - handler as (status: YjsProviderStatusPayload) => void - ) - } else if (event === 'sync') { - this.syncListeners.add( - handler as (synced: YjsProviderSyncedPayload) => void - ) - } else { - this.syncedListeners.add( - handler as (synced: YjsProviderSyncedPayload) => void - ) - } - } -} - class DeferredConnectProvider extends FakeProvider { - override connect() { + override connect(): void { this.calls.push('connect') } } @@ -141,7 +54,7 @@ class DeferredConnectProvider extends FakeProvider { class AsyncDisconnectProvider extends FakeProvider { resolveDisconnect: (() => void) | null = null - override disconnect() { + override disconnect(): Promise { this.calls.push('disconnect') return new Promise((resolve) => { @@ -154,24 +67,24 @@ class AsyncDisconnectProvider extends FakeProvider { } class StatusOnlyProvider extends FakeProvider { - override connect() { + override connect(): void { this.calls.push('connect') this.status = 'connected' } - override disconnect() { + override disconnect(): void { this.calls.push('disconnect') this.status = 'disconnected' } } class FireAndForgetDisconnectProvider extends FakeProvider { - override disconnect() { + override disconnect(): void { this.calls.push('disconnect') } } -const createYjsUpdate = (children: Descendant[]) => { +const createYjsUpdate = (children: readonly Descendant[]): Uint8Array => { const doc = new Y.Doc() createEditor({ @@ -182,24 +95,36 @@ const createYjsUpdate = (children: Descendant[]) => { rootName: 'slate', }), ], - initialValue: children, + initialValue: [...children], }) return Y.encodeStateAsUpdate(doc) } -const seedProviderDoc = ( +const applyProviderDoc = ( provider: FakeProvider, - children: Descendant[] = initialValue() -) => { + children: readonly Descendant[] +): void => { Y.applyUpdate(provider.doc, createYjsUpdate(children)) - provider.synced = true } -const createProviderEditor = ( +const seedProviderDoc = ( provider: FakeProvider, - options: Partial = {} -) => { + children: readonly Descendant[] = initialValue() +): void => { + applyProviderDoc(provider, children) + provider.emitSync(true) +} + +const insertFirstBlockTextAtEnd = (editor: SlateEditor, text = '!'): void => { + editor.update((tx) => { + tx.text.insert(text, { + at: { path: [0, 0], offset: Editor.string(editor, [0]).length }, + }) + }) +} + +const createInitialEditor = (): SlateEditor => { const editor = createEditor() Editor.replace(editor, { @@ -208,6 +133,15 @@ const createProviderEditor = ( selection: null, }) + return editor +} + +const createProviderEditor = ( + provider: FakeProvider, + options: Partial = {} +): ProviderEditor => { + const editor = createInitialEditor() + const cleanup = editor.extend( createYjsExtension({ clientId: 'provider-peer', @@ -223,15 +157,9 @@ const createProviderEditor = ( const createProviderEditorWithHistory = ( provider: FakeProvider, order: 'history-first' | 'yjs-first' -) => { - const editor = createEditor() - const cleanups: (() => void)[] = [] - - Editor.replace(editor, { - children: initialValue(), - marks: null, - selection: null, - }) +): ProviderEditor => { + const editor = createInitialEditor() + const cleanups: Cleanup[] = [] if (order === 'history-first') { cleanups.push(editor.extend(history())) @@ -252,7 +180,7 @@ const createProviderEditorWithHistory = ( } return { - cleanup: () => { + cleanup: (): void => { for (const cleanup of [...cleanups].reverse()) { cleanup() } @@ -262,37 +190,66 @@ const createProviderEditorWithHistory = ( } describe('@slate/yjs provider contract', () => { + it('passes provider string statuses through', () => { + assert.equal(normalizeYjsProviderStatus('connected'), 'connected') + assert.equal( + normalizeYjsProviderStatus({ status: 'disconnected' }), + 'disconnected' + ) + assert.equal(normalizeYjsProviderStatus('open'), 'open') + assert.equal(normalizeYjsProviderStatus({ status: 'stale' }), 'stale') + }) + + it('normalizes only boolean provider synced payloads', () => { + assert.equal(normalizeYjsProviderSynced(true), true) + assert.equal(normalizeYjsProviderSynced({ state: false }), false) + assert.equal(normalizeYjsProviderSynced({ synced: true }), true) + assert.equal(normalizeYjsProviderSynced('true'), null) + assert.equal(normalizeYjsProviderSynced({ state: 'false' }), null) + assert.equal(normalizeYjsProviderSynced({ synced: 1 }), null) + }) + + it('derives connection state from provider status with null fallback only', () => { + assert.equal(connectedFromYjsProviderStatus('connected', false), true) + assert.equal(connectedFromYjsProviderStatus('connecting', true), false) + assert.equal(connectedFromYjsProviderStatus('disconnected', true), false) + assert.equal(connectedFromYjsProviderStatus('open', true), true) + assert.equal(connectedFromYjsProviderStatus('open', false), false) + assert.equal(connectedFromYjsProviderStatus(null, true), true) + assert.equal(connectedFromYjsProviderStatus(null, false), false) + }) + it('returns nullable provider state without a provider', () => { const peer = createYjsPeer({ children: initialValue(), clientId: 'a', }) - assert.equal(getYjsState(peer).providerStatus(), null) - assert.equal(getYjsState(peer).providerSynced(), null) + assert.equal(getYjsProviderStatus(peer), null) + assert.equal(getYjsProviderSynced(peer), null) runYjsUpdate(peer, (yjs) => { yjs.disconnect() - assert.equal(getYjsState(peer).connected(), false) + assert.equal(isYjsPeerConnected(peer), false) yjs.reconnect() }) - assert.equal(getYjsState(peer).connected(), true) + assert.equal(isYjsPeerConnected(peer), true) }) it('uses provider doc and awareness as additive defaults', () => { const provider = new FakeProvider() seedProviderDoc(provider) const { cleanup, editor } = createProviderEditor(provider) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) assert.equal(yjs.doc(), provider.doc) assert.equal(yjs.providerStatus(), 'disconnected') assert.equal(yjs.providerSynced(), true) assert.equal(yjs.connected(), false) - editor.update((tx) => { - ;(tx as any).yjs.sendSelection(selection(), { name: 'Provider peer' }) + runEditorYjsUpdate(editor, (yjs) => { + yjs.sendSelection(selection(), { name: 'Provider peer' }) }) assert.deepEqual(provider.awareness.getLocalState()?.data, { @@ -305,7 +262,7 @@ describe('@slate/yjs provider contract', () => { it('subscribes to provider status and provider-reported sync changes', () => { const provider = new FakeProvider() const { cleanup, editor } = createProviderEditor(provider) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) const seen: [YjsProviderStatus | null, boolean | null][] = [] const unsubscribe = yjs.subscribeProvider(() => { seen.push([yjs.providerStatus(), yjs.providerSynced()]) @@ -337,7 +294,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(root.length, 0) - Y.applyUpdate(provider.doc, createYjsUpdate([paragraph('remote')])) + applyProviderDoc(provider, [paragraph('remote')]) assert.equal(Editor.string(editor, [0]), 'remote') @@ -354,8 +311,8 @@ describe('@slate/yjs provider contract', () => { const { cleanup, editor } = createProviderEditor(provider) const root = provider.doc.get('slate', Y.XmlElement) - editor.update((tx) => { - ;(tx as any).yjs.reconcile() + runEditorYjsUpdate(editor, (yjs) => { + yjs.reconcile() }) assert.equal(Editor.string(editor, [0]), 'alpha') @@ -372,21 +329,13 @@ describe('@slate/yjs provider contract', () => { order ) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) await Promise.resolve() assert.equal(Editor.string(editor, [0]), 'alpha', order) - assert.equal( - editor.read((state) => (state as any).history.undos().length), - 0, - order - ) + assert.equal(getHistoryUndoCount(editor), 0, order) - editor.update((tx) => { - ;(tx as any).history.undo() - }) + undoEditorHistory(editor) assert.equal(Editor.string(editor, [0]), 'alpha', order) @@ -399,14 +348,12 @@ describe('@slate/yjs provider contract', () => { const { cleanup, editor } = createProviderEditor(provider) const root = provider.doc.get('slate', Y.XmlElement) - Y.applyUpdate(provider.doc, createYjsUpdate([paragraph('remote')])) + applyProviderDoc(provider, [paragraph('remote')]) assert.equal(Editor.string(editor, [0]), 'remote') assert.equal(root.length, 1) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'remote'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'remote!') @@ -429,9 +376,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 2) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha!') assert.equal(root.length, 2) @@ -451,9 +396,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) @@ -468,9 +411,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(root.length, 0) assert.doesNotThrow(() => { - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) }) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) @@ -480,9 +421,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 2) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha!') assert.equal(root.length, 2) @@ -495,14 +434,12 @@ describe('@slate/yjs provider contract', () => { const { cleanup, editor } = createProviderEditor(provider) const root = provider.doc.get('slate', Y.XmlElement) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) - Y.applyUpdate(provider.doc, createYjsUpdate([paragraph('remote')])) + applyProviderDoc(provider, [paragraph('remote')]) assert.equal(Editor.string(editor, [0]), 'remote') assert.equal(root.length, 1) @@ -516,16 +453,13 @@ describe('@slate/yjs provider contract', () => { }) it('does not seed provider docs with unknown sync state by default', () => { - const provider = new FakeProvider() - delete (provider as Partial).synced + const provider = new FakeProvider({ exposeSynced: false }) const { cleanup, editor } = createProviderEditor(provider) const root = provider.doc.get('slate', Y.XmlElement) assert.equal(root.length, 0) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) @@ -534,8 +468,7 @@ describe('@slate/yjs provider contract', () => { }) it('does not seed provider docs with unknown sync state when explicitly requested', () => { - const provider = new FakeProvider() - delete (provider as Partial).synced + const provider = new FakeProvider({ exposeSynced: false }) const { cleanup, editor } = createProviderEditor(provider, { seedProviderOnSync: true, }) @@ -543,9 +476,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(root.length, 0) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) @@ -568,9 +499,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(root.length, 0) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) @@ -584,9 +513,8 @@ describe('@slate/yjs provider contract', () => { }) it('sync-gates explicit docs even when providers do not expose a doc property', () => { - const provider = new FakeProvider() - const doc = provider.doc - delete (provider as Partial).doc + const doc = new Y.Doc() + const provider = new FakeProvider({ doc, exposeDoc: false }) const { cleanup, editor } = createProviderEditor(provider, { doc, seedProviderOnSync: true, @@ -595,9 +523,7 @@ describe('@slate/yjs provider contract', () => { assert.equal(root.length, 0) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 0) @@ -623,14 +549,12 @@ describe('@slate/yjs provider contract', () => { assert.equal(Editor.string(editor, [0]), 'alpha') assert.equal(root.length, 2) - editor.update((tx) => { - tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) - }) + insertFirstBlockTextAtEnd(editor) assert.equal(Editor.string(editor, [0]), 'alpha!') - editor.update((tx) => { - ;(tx as any).yjs.undo() + runEditorYjsUpdate(editor, (yjs) => { + yjs.undo() }) assert.equal(Editor.string(editor, [0]), 'alpha') @@ -639,14 +563,13 @@ describe('@slate/yjs provider contract', () => { }) it('uses provider status events as the remote cursor visibility gate', () => { - const provider = new FakeProvider() - provider.status = 'connected' + const provider = new FakeProvider({ status: 'connected' }) seedProviderDoc(provider) const { cleanup, editor } = createProviderEditor(provider) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) - editor.update((tx) => { - ;(tx as any).yjs.sendSelection(selection(), { name: 'Remote peer' }) + runEditorYjsUpdate(editor, (yjs) => { + yjs.sendSelection(selection(), { name: 'Remote peer' }) }) provider.awareness.setRemoteState(88, { data: { name: 'Remote peer' }, @@ -673,10 +596,10 @@ describe('@slate/yjs provider contract', () => { const provider = new DeferredConnectProvider() seedProviderDoc(provider) const { cleanup, editor } = createProviderEditor(provider) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) - editor.update((tx) => { - ;(tx as any).yjs.sendSelection(selection(), { name: 'Remote peer' }) + runEditorYjsUpdate(editor, (yjs) => { + yjs.sendSelection(selection(), { name: 'Remote peer' }) }) provider.awareness.setRemoteState(88, { data: { name: 'Remote peer' }, @@ -686,8 +609,8 @@ describe('@slate/yjs provider contract', () => { assert.equal(yjs.connected(), false) assert.deepEqual(yjs.remoteCursors(), []) - editor.update((tx) => { - ;(tx as any).yjs.connect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.connect() }) assert.deepEqual(provider.calls, ['connect']) @@ -707,21 +630,21 @@ describe('@slate/yjs provider contract', () => { const provider = new StatusOnlyProvider() seedProviderDoc(provider) const { cleanup, editor } = createProviderEditor(provider) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) assert.equal(yjs.providerStatus(), 'disconnected') assert.equal(yjs.connected(), false) - editor.update((tx) => { - ;(tx as any).yjs.connect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.connect() }) assert.deepEqual(provider.calls, ['connect']) assert.equal(yjs.providerStatus(), 'connected') assert.equal(yjs.connected(), true) - editor.update((tx) => { - ;(tx as any).yjs.disconnect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.disconnect() }) assert.deepEqual(provider.calls, ['connect', 'disconnect']) @@ -732,16 +655,17 @@ describe('@slate/yjs provider contract', () => { }) it('keeps local disconnect authoritative while provider status is stale', () => { - const provider = new FireAndForgetDisconnectProvider() - provider.status = 'connected' + const provider = new FireAndForgetDisconnectProvider({ + status: 'connected', + }) seedProviderDoc(provider) const { cleanup, editor } = createProviderEditor(provider) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) assert.equal(yjs.connected(), true) - editor.update((tx) => { - ;(tx as any).yjs.disconnect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.disconnect() }) assert.deepEqual(provider.calls, ['disconnect']) @@ -755,19 +679,13 @@ describe('@slate/yjs provider contract', () => { const provider = new FakeProvider() const { cleanup, editor } = createProviderEditor(provider) - editor.update((tx) => { - ;(tx as any).yjs.reconnect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.reconnect() }) assert.deepEqual(provider.calls, ['disconnect', 'connect']) - assert.equal( - editor.read((state) => (state as any).yjs.connected()), - true - ) - assert.equal( - editor.read((state) => (state as any).yjs.providerStatus()), - 'connected' - ) + assert.equal(readEditorYjsState(editor).connected(), true) + assert.equal(readEditorYjsState(editor).providerStatus(), 'connected') cleanup() }) @@ -776,8 +694,8 @@ describe('@slate/yjs provider contract', () => { const provider = new AsyncDisconnectProvider() const { cleanup, editor } = createProviderEditor(provider) - editor.update((tx) => { - ;(tx as any).yjs.reconnect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.reconnect() }) assert.deepEqual(provider.calls, ['disconnect']) @@ -794,12 +712,12 @@ describe('@slate/yjs provider contract', () => { const provider = new FakeProvider() const { cleanup, editor } = createProviderEditor(provider) - editor.update((tx) => { - ;(tx as any).yjs.pause() - ;(tx as any).yjs.disconnect() + runEditorYjsUpdate(editor, (yjs) => { + yjs.pause() + yjs.disconnect() }) - const yjs = editor.read((state) => (state as any).yjs) + const yjs = readEditorYjsState(editor) assert.equal(yjs.paused(), true) assert.equal(yjs.connected(), false) @@ -813,14 +731,12 @@ describe('@slate/yjs provider contract', () => { seedProviderDoc(provider) const { cleanup, editor } = createProviderEditor(provider) let notifications = 0 - const unsubscribe = editor - .read((state) => (state as any).yjs) - .subscribeProvider(() => { - notifications += 1 - }) + const unsubscribe = readEditorYjsState(editor).subscribeProvider(() => { + notifications += 1 + }) - editor.update((tx) => { - ;(tx as any).yjs.sendSelection(selection(), { name: 'Provider peer' }) + runEditorYjsUpdate(editor, (yjs) => { + yjs.sendSelection(selection(), { name: 'Provider peer' }) }) cleanup() @@ -857,8 +773,7 @@ describe('@slate/yjs provider contract', () => { }) it('destroys providers only when explicitly owned by the editor', () => { - const provider = new FakeProvider() - provider.synced = true + const provider = new FakeProvider({ synced: true }) const { cleanup } = createProviderEditor(provider, { destroyProviderOnUnmount: true, }) diff --git a/packages/slate-yjs/test/react-contract.spec.tsx b/packages/slate-yjs/test/react-contract.spec.tsx index 9e82c59074..bfd6415c09 100644 --- a/packages/slate-yjs/test/react-contract.spec.tsx +++ b/packages/slate-yjs/test/react-contract.spec.tsx @@ -4,15 +4,9 @@ import { GlobalRegistrator } from '@happy-dom/global-registrator' import React, { act, useEffect } from 'react' import { createRoot } from 'react-dom/client' import type { Descendant, Editor, Range } from 'slate' -import type * as Y from 'yjs' - -import type { - YjsProviderEvent, - YjsProviderEventHandler, - YjsProviderLike, - YjsProviderStatus, - YjsRemoteCursorDecorationData, -} from '../src' +import type { SlateDecorationSource } from 'slate-react' + +import type { YjsRemoteCursorDecorationData } from '../src' import { useYjsProviderStatus, useYjsProviderSynced, @@ -22,8 +16,11 @@ import { import { createYjsPeer, FakeAwareness, + FakeProvider, type Peer, + paragraph, runYjsUpdate, + setEditorDomApi, } from './support/collaboration' const shouldUnregisterHappyDOM = !GlobalRegistrator.isRegistered @@ -41,23 +38,40 @@ after(() => { } }) -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) - -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), ] -const selection = (path = [0, 0], offset = 1): Range => ({ +const selection = ( + path: Range['anchor']['path'] = [0, 0], + offset = 1 +): Range => ({ anchor: { path, offset }, focus: { path, offset: offset + 2 }, }) -const render = (element: React.ReactNode) => { +type CursorData = { + readonly color: string + readonly name: string +} + +type LabelDecorationData = { + readonly clientId: number + readonly label: string +} + +type RenderedView = { + readonly container: HTMLDivElement + readonly unmount: () => void +} + +type EditorProbeProps = { + readonly editor: Editor +} + +const render = (element: React.ReactNode): RenderedView => { const container = document.createElement('div') document.body.append(container) const root = createRoot(container) @@ -82,7 +96,7 @@ const sendRemoteSelection = ( awareness: FakeAwareness, range: Range, clientId = 101 -) => { +): void => { runYjsUpdate(peer, (yjs) => { yjs.sendSelection(range) awareness.setRemoteState(clientId, { @@ -92,60 +106,21 @@ const sendRemoteSelection = ( }) } -class FakeProvider implements YjsProviderLike { - awareness = new FakeAwareness(7) - doc?: Y.Doc - status: YjsProviderStatus = 'connecting' - synced = false - - private readonly statusListeners = new Set< - (status: YjsProviderStatus) => void - >() - private readonly syncedListeners = new Set<(synced: boolean) => void>() - - emitStatus(status: YjsProviderStatus) { - this.status = status - for (const listener of this.statusListeners) { - listener(status) - } - } - - emitSynced(synced: boolean) { - this.synced = synced - for (const listener of this.syncedListeners) { - listener(synced) - } - } - - off(event: YjsProviderEvent, handler: YjsProviderEventHandler) { - if (event === 'status') { - this.statusListeners.delete( - handler as (status: YjsProviderStatus) => void - ) - } else { - this.syncedListeners.delete(handler as (synced: boolean) => void) - } - } - - on(event: YjsProviderEvent, handler: YjsProviderEventHandler) { - if (event === 'status') { - this.statusListeners.add(handler as (status: YjsProviderStatus) => void) - } else { - this.syncedListeners.add(handler as (synced: boolean) => void) - } - } -} - describe('@slate/yjs react contract', () => { it('rerenders provider status hooks from provider lifecycle events', () => { - const provider = new FakeProvider() + const provider = new FakeProvider({ + awarenessClientId: 7, + status: 'connecting', + }) const peer = createYjsPeer({ children: initialValue(), clientId: 'a', provider, }) - const ProviderProbe = ({ editor }: { editor: Editor }) => { + const ProviderProbe = ({ + editor, + }: EditorProbeProps): React.ReactElement => { const status = useYjsProviderStatus(editor) const synced = useYjsProviderSynced(editor) @@ -182,18 +157,19 @@ describe('@slate/yjs react contract', () => { clientId: 'b', numericClientId: 2, }) - ;(peer.editor as any).api = { - ...(peer.editor as any).api, - dom: { - isFocused: () => true, - }, - } - let source: ReturnType | null = - null + + setEditorDomApi(peer.editor, { isFocused: () => true }) + + let source: SlateDecorationSource< + YjsRemoteCursorDecorationData + > | null = null let lastRefreshRequiresDOMSelectionExport: boolean | null = null - const DecorationProbe = ({ editor }: { editor: Editor }) => { - const cursorSource = useYjsRemoteCursorDecorationSource(editor) + const DecorationProbe = ({ + editor, + }: EditorProbeProps): React.ReactElement | null => { + const cursorSource = + useYjsRemoteCursorDecorationSource(editor) useEffect(() => { source = cursorSource @@ -215,25 +191,16 @@ describe('@slate/yjs react contract', () => { assert.ok(source) const slices = Object.values(source.getSnapshot()).flat() + const [slice] = slices assert.equal(lastRefreshRequiresDOMSelectionExport, true) assert.equal(slices.length, 1) - assert.equal( - ( - slices[0]?.data as - | YjsRemoteCursorDecorationData<{ color: string; name: string }> - | undefined - )?.clientId, - 101 - ) - assert.deepEqual( - ( - slices[0]?.data as - | YjsRemoteCursorDecorationData<{ color: string; name: string }> - | undefined - )?.data, - { color: 'tomato', name: 'Ada' } - ) + assert.ok(slice) + + const data = slice.data + + assert.equal(data.clientId, 101) + assert.deepEqual(data.data, { color: 'tomato', name: 'Ada' }) view.unmount() peer.cleanup() @@ -247,19 +214,16 @@ describe('@slate/yjs react contract', () => { clientId: 'd', numericClientId: 4, }) - let source: ReturnType< - typeof useYjsRemoteCursorDecorationSource< - { color: string; name: string }, - { clientId: number; label: string } - > - > | null = null + let source: SlateDecorationSource | null = null let setLabel: ((label: string) => void) | null = null - const DecorationProbe = ({ editor }: { editor: Editor }) => { + const DecorationProbe = ({ + editor, + }: EditorProbeProps): React.ReactElement | null => { const [label, updateLabel] = React.useState('Ada') const cursorSource = useYjsRemoteCursorDecorationSource< - { color: string; name: string }, - { clientId: number; label: string } + CursorData, + LabelDecorationData >(editor, { decorate: (cursor) => ({ clientId: cursor.clientId, label }), deps: [label], @@ -306,25 +270,11 @@ describe('@slate/yjs react contract', () => { clientId: 'c', numericClientId: 3, }) - let rect = { - bottom: 40, - height: 20, - left: 10, - right: 30, - top: 20, - width: 20, - x: 10, - y: 20, - } as DOMRect - - ;(peer.editor as any).api = { - ...(peer.editor as any).api, - dom: { - resolveRangeRect: () => rect, - }, - } + let rect = new DOMRect(10, 20, 20, 20) + + setEditorDomApi(peer.editor, { resolveRangeRect: () => rect }) - const OverlayProbe = ({ editor }: { editor: Editor }) => { + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { const [positions] = useYjsRemoteCursorOverlayPositions(editor) return ( @@ -345,12 +295,7 @@ describe('@slate/yjs react contract', () => { assert.equal(view.container.textContent, '101:10') act(() => { - rect = { - ...rect, - left: 25, - right: 45, - x: 25, - } + rect = new DOMRect(25, 20, 20, 20) peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) @@ -362,6 +307,37 @@ describe('@slate/yjs react contract', () => { peer.cleanup() }) + it('ignores malformed remote cursor overlay rectangles', () => { + const awareness = new FakeAwareness(6) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'f', + numericClientId: 6, + }) + + setEditorDomApi(peer.editor, { + resolveRangeRect: () => ({ x: 10 }), + }) + + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { + const [positions] = useYjsRemoteCursorOverlayPositions(editor) + + return {String(positions[0]?.rect === null)} + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + }) + + assert.equal(view.container.textContent, 'true') + + view.unmount() + peer.cleanup() + }) + it('refreshes remote cursor overlay data when overlay deps change', () => { const awareness = new FakeAwareness(5) const peer = createYjsPeer({ @@ -372,14 +348,9 @@ describe('@slate/yjs react contract', () => { }) let setLabel: ((label: string) => void) | null = null - ;(peer.editor as any).api = { - ...(peer.editor as any).api, - dom: { - resolveRangeRect: () => null, - }, - } + setEditorDomApi(peer.editor, { resolveRangeRect: () => null }) - const OverlayProbe = ({ editor }: { editor: Editor }) => { + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { const [label, updateLabel] = React.useState('Ada') const [positions] = useYjsRemoteCursorOverlayPositions< { color: string; name: string }, diff --git a/packages/slate-yjs/test/record-contract.spec.ts b/packages/slate-yjs/test/record-contract.spec.ts new file mode 100644 index 0000000000..7470065337 --- /dev/null +++ b/packages/slate-yjs/test/record-contract.spec.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { isRecord } from '../src/core/record' + +describe('@slate/yjs record guard contract', () => { + it('accepts plain objects but not arrays', () => { + assert.equal(isRecord({ key: 'value' }), true) + assert.equal(isRecord([]), false) + assert.equal(isRecord(null), false) + }) +}) diff --git a/packages/slate-yjs/test/remove-node-contract.spec.ts b/packages/slate-yjs/test/remove-node-contract.spec.ts index 505b79a6c1..b4bb5e7b36 100644 --- a/packages/slate-yjs/test/remove-node-contract.spec.ts +++ b/packages/slate-yjs/test/remove-node-contract.spec.ts @@ -2,23 +2,22 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Descendant } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, - getYjsState, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, + getYjsTrace, type Peer, - runYjsUpdate, + paragraph, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) - -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), @@ -27,23 +26,16 @@ const initialValue = () => [ const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => createYjsPeer({ children: initialValue(), clientId, seedUpdate }) -const createPeers = (clientIds: string[]) => { - return createSeededYjsPeers({ children: initialValue(), clientIds }) -} - -const yjsState = getYjsState -const yjsUpdate = runYjsUpdate -const paragraphTexts = getParagraphTexts -const syncConnected = syncConnectedPeers -const assertAllTexts = assertPeerTexts +const createPeers = (clientIds: readonly string[]): Peer[] => + createSeededYjsPeers({ children: initialValue(), clientIds }) -const removeMiddleBlock = (peer: Peer) => { +const removeMiddleBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.remove({ at: [1] }) }) } -const insertRemoteText = (peer: Peer) => { +const insertRemoteText = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) @@ -53,68 +45,59 @@ describe('@slate/yjs remove_node collaboration contract', () => { it('applies local offline remove_node without a root snapshot fallback', () => { const peer = createPeer('b') - yjsUpdate(peer, (yjs) => yjs.disconnect()) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) removeMiddleBlock(peer) - assert.deepEqual(paragraphTexts(peer), ['alpha', 'gamma']) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'gamma']) + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'remove_node' }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote sibling edits when an offline remove_node reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) removeMiddleBlock(b) insertRemoteText(a) - syncConnected(peers) + syncConnectedPeers(peers) - assert.deepEqual(paragraphTexts(a), ['alpha!', 'beta', 'gamma']) - assert.deepEqual(paragraphTexts(b), ['alpha', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha', 'gamma']) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alpha!', 'gamma']) - assertNoRootSnapshot(b) + assertPeerTexts(peers, ['alpha!', 'gamma']) }) it('recovers convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) removeMiddleBlock(b) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alpha', 'gamma']) + assertPeerTexts(peers, ['alpha', 'gamma']) }) it('undoes and redoes only the local remove_node intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) removeMiddleBlock(b) insertRemoteText(a) - syncConnected(peers) + syncConnectedPeers(peers) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!', 'gamma']) + connectYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!', 'gamma']) - yjsUpdate(b, (yjs) => yjs.undo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!', 'beta', 'gamma']) + undoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) - yjsUpdate(b, (yjs) => yjs.redo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha!', 'gamma']) - assertNoRootSnapshot(b) + redoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha!', 'gamma']) }) }) diff --git a/packages/slate-yjs/test/replace-fragment-contract.spec.ts b/packages/slate-yjs/test/replace-fragment-contract.spec.ts index efcc9f5b0d..d689c494ed 100644 --- a/packages/slate-yjs/test/replace-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/replace-fragment-contract.spec.ts @@ -2,16 +2,23 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Descendant, Operation } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + clearYjsTrace, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getYjsNodeAt, - getYjsState, + getYjsTrace, type Peer, - runYjsUpdate, + paragraph, + redoYjsPeer, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeer, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -19,26 +26,23 @@ const clientIds = { b: 2, c: 3, } as const -const numericClientIds: Record = { ...clientIds } -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds +const numericClientIds: Readonly> = { ...clientIds } -const initialValue = () => [paragraph('alpha')] +const initialValue = (): Descendant[] => [paragraph('alpha')] const multiLeafValue = (): Descendant[] => [ { type: 'paragraph', children: [{ text: 'alpha' }, { bold: true, text: ' beta' }], - } as Descendant, + }, ] const createPeer = ( - clientId: keyof typeof clientIds, + clientId: ClientId, seedUpdate?: Uint8Array, - children = initialValue() + children: readonly Descendant[] = initialValue() ): Peer => createYjsPeer({ children, @@ -47,22 +51,14 @@ const createPeer = ( seedUpdate, }) -const createPeers = (ids: Array) => { - return createSeededYjsPeers({ +const createPeers = (ids: readonly ClientId[]): Peer[] => + createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds, }) -} - -const yjsState = getYjsState -const yjsUpdate = runYjsUpdate -const paragraphTexts = getParagraphTexts -const yjsNodeAt = getYjsNodeAt -const syncConnected = syncConnectedPeers -const assertAllTexts = assertPeerTexts -const replaceAlphaWithFragment = (peer: Peer) => { +const replaceAlphaWithFragment = (peer: Peer): void => { const operation: Operation = { children: [{ text: 'alpha' }], newChildren: [{ text: 'alphaLin fragment' }], @@ -77,7 +73,7 @@ const replaceAlphaWithFragment = (peer: Peer) => { }) } -const replaceMultiLeafTextWithFragment = (peer: Peer) => { +const replaceMultiLeafTextWithFragment = (peer: Peer): void => { const operation: Operation = { children: [{ text: 'alpha' }, { bold: true, text: ' beta' }], newChildren: [{ text: 'alphaLin' }, { bold: true, text: ' betaAda' }], @@ -92,7 +88,7 @@ const replaceMultiLeafTextWithFragment = (peer: Peer) => { }) } -const replaceRootWithFallback = (peer: Peer) => { +const replaceRootWithFallback = (peer: Peer): void => { const operation: Operation = { children: initialValue(), newChildren: [paragraph('bravo'), paragraph('charlie')], @@ -107,19 +103,19 @@ const replaceRootWithFallback = (peer: Peer) => { }) } -const appendRemoteText = (peer: Peer) => { +const appendRemoteText = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert(' Ada', { at: { path: [0, 0], offset: 'alpha'.length } }) }) } -const insertLocalBang = (peer: Peer) => { +const insertLocalBang = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) } -const replayNoopRootReplaceFragment = (peer: Peer) => { +const replayNoopRootReplaceFragment = (peer: Peer): void => { const operation: Operation = { children: initialValue(), newChildren: initialValue(), @@ -140,126 +136,114 @@ const replayNoopRootReplaceFragment = (peer: Peer) => { describe('@slate/yjs replace_fragment collaboration contract', () => { it('applies local offline single-text replace_fragment without replacing the Yjs text node', () => { const peer = createPeer('b') - const text = yjsNodeAt(peer, [0, 0]) + const text = getYjsNodeAt(peer, [0, 0]) - yjsUpdate(peer, (yjs) => yjs.disconnect()) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) replaceAlphaWithFragment(peer) - assert.deepEqual(paragraphTexts(peer), ['alphaLin fragment']) - assert.equal(yjsNodeAt(peer, [0, 0]), text) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(peer), ['alphaLin fragment']) + assert.equal(getYjsNodeAt(peer, [0, 0]), text) + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'replace_fragment' }, ]) - assertNoRootSnapshot(peer) }) it('preserves every Yjs text node for same-width multi-leaf replace_fragment', () => { const peer = createPeer('b', undefined, multiLeafValue()) - const firstText = yjsNodeAt(peer, [0, 0]) - const secondText = yjsNodeAt(peer, [0, 1]) + const firstText = getYjsNodeAt(peer, [0, 0]) + const secondText = getYjsNodeAt(peer, [0, 1]) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) replaceMultiLeafTextWithFragment(peer) - assert.deepEqual(paragraphTexts(peer), ['alphaLin betaAda']) - assert.equal(yjsNodeAt(peer, [0, 0]), firstText) - assert.equal(yjsNodeAt(peer, [0, 1]), secondText) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(peer), ['alphaLin betaAda']) + assert.equal(getYjsNodeAt(peer, [0, 0]), firstText) + assert.equal(getYjsNodeAt(peer, [0, 1]), secondText) + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'replace_fragment' }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline replace_fragment reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) replaceAlphaWithFragment(b) appendRemoteText(a) - syncConnected(peers) + syncConnectedPeers(peers) - assert.deepEqual(paragraphTexts(a), ['alpha Ada']) - assert.deepEqual(paragraphTexts(b), ['alphaLin fragment']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha Ada']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alphaLin fragment']) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alpha AdaLin fragment']) - assertNoRootSnapshot(b) + assertPeerTexts(peers, ['alpha AdaLin fragment']) }) it('recovers replace_fragment convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) replaceAlphaWithFragment(b) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alphaLin fragment']) + assertPeerTexts(peers, ['alphaLin fragment']) }) it('undoes and redoes only the local replace_fragment intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) replaceAlphaWithFragment(b) appendRemoteText(a) - syncConnected(peers) + syncConnectedPeers(peers) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['alpha AdaLin fragment']) + connectYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha AdaLin fragment']) - yjsUpdate(b, (yjs) => yjs.undo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha Ada']) + undoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha Ada']) - yjsUpdate(b, (yjs) => yjs.redo()) - syncConnected(peers) - assertAllTexts(peers, ['alpha AdaLin fragment']) - assertNoRootSnapshot(b) + redoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alpha AdaLin fragment']) }) it('ignores no-op replace_fragment so redo history stays usable', () => { const peer = createPeer('b') insertLocalBang(peer) - assert.deepEqual(paragraphTexts(peer), ['alpha!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!']) - yjsUpdate(peer, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(peer), ['alpha']) + undoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha']) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) replayNoopRootReplaceFragment(peer) - assert.deepEqual(paragraphTexts(peer), ['alpha']) - assert.deepEqual(yjsState(peer).trace(), []) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha']) + assert.deepEqual(getYjsTrace(peer), []) - yjsUpdate(peer, (yjs) => yjs.redo()) - assert.deepEqual(paragraphTexts(peer), ['alpha!']) - assertNoRootSnapshot(peer) + redoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha!']) }) it('uses a traceable fallback for broad replace_fragment replacement', () => { const peer = createPeer('b') - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) replaceRootWithFallback(peer) - assert.deepEqual(paragraphTexts(peer), ['bravo', 'charlie']) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(peer), ['bravo', 'charlie']) + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'replace-fragment-scoped-replace-identity-risk', mode: 'traceable-fallback', operationType: 'replace_fragment', }, ]) - assertNoRootSnapshot(peer) }) }) diff --git a/packages/slate-yjs/test/selection-contract.spec.ts b/packages/slate-yjs/test/selection-contract.spec.ts index cde9161963..94bcf46178 100644 --- a/packages/slate-yjs/test/selection-contract.spec.ts +++ b/packages/slate-yjs/test/selection-contract.spec.ts @@ -9,10 +9,13 @@ import { yjsRelativeRangeToSlateRange, } from '../src' import { + clearYjsTrace, createSeededYjsPeers, createYjsPeer, - getYjsState, - runYjsUpdate, + getYjsRoot, + getYjsTrace, + type Peer, + paragraph, syncConnectedPeers, } from './support/collaboration' @@ -22,46 +25,41 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), ] -const createPeer = (clientId: keyof typeof clientIds) => +const createPeer = (clientId: ClientId): Peer => createYjsPeer({ children: initialValue(), clientId, numericClientId: clientIds[clientId], }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const root = (peer: ReturnType) => getYjsState(peer).root() - -const moveFirstBlockToEnd = (peer: ReturnType) => { +const moveFirstBlockToEnd = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.move({ at: [0], to: [2] }) }) } -const insertInsideAlpha = (peer: ReturnType) => { +const insertInsideAlpha = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 2 } }) }) } -const removeFirstBlock = (peer: ReturnType) => { +const removeFirstBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.remove({ at: [0] }) }) @@ -71,29 +69,59 @@ describe('@slate/yjs selection relative-position contract', () => { it('round trips a Slate point through a Yjs relative position', () => { const peer = createPeer('b') const point = { path: [0, 0], offset: 3 } - const relative = slatePointToYjsRelativePosition(root(peer), point) + const relative = slatePointToYjsRelativePosition(getYjsRoot(peer), point) assert.deepEqual( - yjsRelativePositionToSlatePoint(root(peer), relative), + yjsRelativePositionToSlatePoint(getYjsRoot(peer), relative), point ) }) + it('clamps Slate point offsets to text bounds before storing relative positions', () => { + const peer = createPeer('b') + const beforeStart = slatePointToYjsRelativePosition(getYjsRoot(peer), { + path: [0, 0], + offset: -10, + }) + const afterEnd = slatePointToYjsRelativePosition(getYjsRoot(peer), { + path: [0, 0], + offset: 99, + }) + + assert.deepEqual( + yjsRelativePositionToSlatePoint(getYjsRoot(peer), beforeStart), + { + path: [0, 0], + offset: 0, + } + ) + assert.deepEqual( + yjsRelativePositionToSlatePoint(getYjsRoot(peer), afterEnd), + { + path: [0, 0], + offset: 'alpha'.length, + } + ) + }) + it('round trips a Slate range without changing anchor/focus direction', () => { const peer = createPeer('b') const range: Range = { anchor: { path: [1, 0], offset: 4 }, focus: { path: [0, 0], offset: 1 }, } - const relative = slateRangeToYjsRelativeRange(root(peer), range) + const relative = slateRangeToYjsRelativeRange(getYjsRoot(peer), range) - assert.deepEqual(yjsRelativeRangeToSlateRange(root(peer), relative), range) + assert.deepEqual( + yjsRelativeRangeToSlateRange(getYjsRoot(peer), relative), + range + ) }) it('rebases a stored point across a concurrent text insert', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - const relative = slatePointToYjsRelativePosition(root(b), { + const relative = slatePointToYjsRelativePosition(getYjsRoot(b), { path: [0, 0], offset: 3, }) @@ -101,7 +129,7 @@ describe('@slate/yjs selection relative-position contract', () => { insertInsideAlpha(a) syncConnectedPeers(peers) - assert.deepEqual(yjsRelativePositionToSlatePoint(root(b), relative), { + assert.deepEqual(yjsRelativePositionToSlatePoint(getYjsRoot(b), relative), { path: [0, 0], offset: 4, }) @@ -109,40 +137,46 @@ describe('@slate/yjs selection relative-position contract', () => { it('resolves a stored point through virtual moved-node identity', () => { const peer = createPeer('b') - const relative = slatePointToYjsRelativePosition(root(peer), { + const relative = slatePointToYjsRelativePosition(getYjsRoot(peer), { path: [0, 0], offset: 2, }) moveFirstBlockToEnd(peer) - assert.deepEqual(yjsRelativePositionToSlatePoint(root(peer), relative), { - path: [2, 0], - offset: 2, - }) + assert.deepEqual( + yjsRelativePositionToSlatePoint(getYjsRoot(peer), relative), + { + path: [2, 0], + offset: 2, + } + ) }) it('returns null when the relative position target is no longer visible', () => { const peer = createPeer('b') - const relative = slatePointToYjsRelativePosition(root(peer), { + const relative = slatePointToYjsRelativePosition(getYjsRoot(peer), { path: [0, 0], offset: 2, }) removeFirstBlock(peer) - assert.equal(yjsRelativePositionToSlatePoint(root(peer), relative), null) + assert.equal( + yjsRelativePositionToSlatePoint(getYjsRoot(peer), relative), + null + ) }) it('does not record selection-only conversions in the Yjs operation trace', () => { const peer = createPeer('b') - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) - slateRangeToYjsRelativeRange(root(peer), { + clearYjsTrace(peer) + slateRangeToYjsRelativeRange(getYjsRoot(peer), { anchor: { path: [0, 0], offset: 1 }, focus: { path: [1, 0], offset: 2 }, }) - assert.deepEqual(getYjsState(peer).trace(), []) + assert.deepEqual(getYjsTrace(peer), []) }) }) diff --git a/packages/slate-yjs/test/set-node-contract.spec.ts b/packages/slate-yjs/test/set-node-contract.spec.ts index 79e4c5d2a0..9d734c994e 100644 --- a/packages/slate-yjs/test/set-node-contract.spec.ts +++ b/packages/slate-yjs/test/set-node-contract.spec.ts @@ -1,23 +1,24 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { - type Descendant, - defineEditorExtension, - type Element, - NodeApi, -} from 'slate' -import { Editor } from 'slate/internal' +import { type Descendant, type Element, NodeApi } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getVisibleYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + readPeerChildren, + recordOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -26,22 +27,15 @@ const clientIds = { c: 3, } as const -const paragraph = ( - text: string, - attributes: Record = {} -): Descendant => ({ - ...attributes, - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [paragraph('alpha')] -const roleValue = () => [paragraph('alpha', { role: 'title' })] +const initialValue = (): Descendant[] => [paragraph('alpha')] +const roleValue = (): Descendant[] => [paragraph('alpha', { role: 'title' })] const createPeer = ( - clientId: keyof typeof clientIds, - children: Descendant[] = initialValue() -) => + clientId: ClientId, + children: readonly Descendant[] = initialValue() +): Peer => createYjsPeer({ children, clientId, @@ -49,34 +43,34 @@ const createPeer = ( }) const createPeers = ( - ids: Array, - children: Descendant[] = initialValue() -) => + ids: readonly ClientId[], + children: readonly Descendant[] = initialValue() +): Peer[] => createSeededYjsPeers({ children, clientIds: ids, numericClientIds: clientIds, }) -const setHeading = (peer: ReturnType) => { +const setHeading = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.set({ role: 'title', type: 'heading-one' }, { at: [0] }) }) } -const unsetRole = (peer: ReturnType) => { +const unsetRole = (peer: Peer): void => { peer.editor.update((tx) => { - tx.nodes.unset('role' as never, { at: [0] }) + tx.nodes.unset('role', { at: [0] }) }) } -const setTextMark = (peer: ReturnType) => { +const setTextMark = (peer: Peer): void => { peer.editor.update((tx) => { - tx.nodes.set({ bold: true } as never, { at: [0, 0], match: NodeApi.isText }) + tx.nodes.set({ bold: true }, { at: [0, 0], match: NodeApi.isText }) }) } -const appendRemoteText = (peer: ReturnType) => { +const appendRemoteText = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) @@ -85,22 +79,9 @@ const appendRemoteText = (peer: ReturnType) => { describe('@slate/yjs set_node collaboration contract', () => { it('characterizes public setNodes as set_node', () => { const peer = createPeer('b') - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'set-node-operation-recorder', - setup() { - return { - onCommit({ commit }) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'set-node-operation-recorder', + }) setHeading(peer) assert.deepEqual(operations, ['set_node']) @@ -110,39 +91,36 @@ describe('@slate/yjs set_node collaboration contract', () => { const peer = createPeer('b') const element = getVisibleYjsNodeAt(peer, [0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) setHeading(peer) - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'heading-one', role: 'title', children: [{ text: 'alpha' }] }, ]) assert.equal(getVisibleYjsNodeAt(peer, [0]), element) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'set_node' }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline element set_node reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) setHeading(b) appendRemoteText(a) syncConnectedPeers(peers) - assert.deepEqual(getParagraphTexts(a), ['alpha!']) - assert.deepEqual(Editor.getSnapshot(b.editor).children, [ + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!']) + assert.deepEqual(readPeerChildren(b), [ { type: 'heading-one', role: 'title', children: [{ text: 'alpha' }] }, ]) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'heading-one', role: 'title', @@ -150,20 +128,18 @@ describe('@slate/yjs set_node collaboration contract', () => { }, ]) } - assertNoRootSnapshot(b) }) it('recovers element set_node convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) setHeading(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'heading-one', role: 'title', children: [{ text: 'alpha' }] }, ]) } @@ -173,15 +149,14 @@ describe('@slate/yjs set_node collaboration contract', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) setHeading(b) appendRemoteText(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'heading-one', role: 'title', @@ -190,18 +165,16 @@ describe('@slate/yjs set_node collaboration contract', () => { ]) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'paragraph', children: [{ text: 'alpha!' }] }, ]) } - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'heading-one', role: 'title', @@ -209,31 +182,17 @@ describe('@slate/yjs set_node collaboration contract', () => { }, ]) } - assertNoRootSnapshot(b) }) it('characterizes public unsetNodes as set_node', () => { const peer = createPeer('b', roleValue()) - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'unset-node-operation-recorder', - setup() { - return { - onCommit({ commit }) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'unset-node-operation-recorder', + }) unsetRole(peer) assert.deepEqual(operations, ['set_node']) - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'paragraph', children: [{ text: 'alpha' }] }, ]) }) @@ -242,43 +201,38 @@ describe('@slate/yjs set_node collaboration contract', () => { const peer = createPeer('b') const text = getVisibleYjsNodeAt(peer, [0, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) setTextMark(peer) - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'paragraph', children: [{ bold: true, text: 'alpha' }] }, ]) assert.equal(getVisibleYjsNodeAt(peer, [0, 0]), text) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'set_node' }, ]) - assertNoRootSnapshot(peer) }) it('syncs text mark set_node through reconnect and undo without root snapshots', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) setTextMark(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'paragraph', children: [{ bold: true, text: 'alpha' }] }, ]) } - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha']) for (const peer of peers) { - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { type: 'paragraph', children: [{ text: 'alpha' }] }, ]) } - assertNoRootSnapshot(b) }) }) diff --git a/packages/slate-yjs/test/simple-operations-contract.spec.ts b/packages/slate-yjs/test/simple-operations-contract.spec.ts index ad77796c25..30589730f1 100644 --- a/packages/slate-yjs/test/simple-operations-contract.spec.ts +++ b/packages/slate-yjs/test/simple-operations-contract.spec.ts @@ -1,17 +1,23 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type Descendant, type Operation } from 'slate' +import type { Descendant, Operation } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getVisibleYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeer, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -20,56 +26,53 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [ +const initialValue = (): Descendant[] => [ paragraph('alpha'), paragraph('beta'), paragraph('gamma'), ] -const createPeer = (clientId: keyof typeof clientIds) => +const createPeer = (clientId: ClientId): Peer => createYjsPeer({ children: initialValue(), clientId, numericClientId: clientIds[clientId], }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const appendRemoteAlpha = (peer: ReturnType) => { +const appendRemoteAlpha = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) } -const insertBetaBang = (peer: ReturnType) => { +const insertBetaBang = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [1, 0], offset: 'beta'.length } }) }) } -const removeBetaMiddle = (peer: ReturnType) => { +const removeBetaMiddle = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.delete({ at: { path: [1, 0], offset: 1 }, distance: 2 }) }) } -const insertMiddleBlock = (peer: ReturnType) => { +const insertMiddleBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.insert([paragraph('bravo')], { at: [1] }) }) } -const replaceMiddleBlock = (peer: ReturnType) => { +const replaceMiddleBlock = (peer: Peer): void => { const operation: Operation = { children: [paragraph('beta')], index: 1, @@ -85,7 +88,7 @@ const replaceMiddleBlock = (peer: ReturnType) => { }) } -const replaceFirstBlock = (peer: ReturnType) => { +const replaceFirstBlock = (peer: Peer): void => { const operation: Operation = { children: [paragraph('alpha')], index: 0, @@ -106,78 +109,66 @@ describe('@slate/yjs simple operation collaboration contract', () => { const peer = createPeer('b') const text = getVisibleYjsNodeAt(peer, [1, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) insertBetaBang(peer) - assert.deepEqual(getParagraphTexts(peer), ['alpha', 'beta!', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'beta!', 'gamma']) assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), text) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'insert_text' }, ]) - assertNoRootSnapshot(peer) }) it('reconnects, undoes, and redoes insert_text while preserving remote edits', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) insertBetaBang(b) appendRemoteAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta!', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta!', 'gamma']) - assertNoRootSnapshot(b) }) it('applies local offline remove_text in place without a root snapshot fallback', () => { const peer = createPeer('b') const text = getVisibleYjsNodeAt(peer, [1, 0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) removeBetaMiddle(peer) - assert.deepEqual(getParagraphTexts(peer), ['alpha', 'ba', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'ba', 'gamma']) assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), text) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'remove_text' }, ]) - assertNoRootSnapshot(peer) }) it('reconnects, undoes, and redoes remove_text while preserving remote edits', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) removeBetaMiddle(b) appendRemoteAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'ba', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'ba', 'gamma']) - assertNoRootSnapshot(b) }) it('applies local offline insert_node without replacing existing Yjs siblings', () => { @@ -185,11 +176,10 @@ describe('@slate/yjs simple operation collaboration contract', () => { const alpha = getVisibleYjsNodeAt(peer, [0]) const beta = getVisibleYjsNodeAt(peer, [1]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) insertMiddleBlock(peer) - assert.deepEqual(getParagraphTexts(peer), [ + assert.deepEqual(getPeerTopLevelTexts(peer), [ 'alpha', 'bravo', 'beta', @@ -197,33 +187,28 @@ describe('@slate/yjs simple operation collaboration contract', () => { ]) assert.equal(getVisibleYjsNodeAt(peer, [0]), alpha) assert.equal(getVisibleYjsNodeAt(peer, [2]), beta) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'insert_node' }, ]) - assertNoRootSnapshot(peer) }) it('reconnects, undoes, and redoes insert_node while preserving remote edits', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) insertMiddleBlock(b) appendRemoteAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'bravo', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'bravo', 'beta', 'gamma']) - assertNoRootSnapshot(b) }) it('applies local offline replace_children while preserving unaffected Yjs siblings', () => { @@ -231,62 +216,54 @@ describe('@slate/yjs simple operation collaboration contract', () => { const alpha = getVisibleYjsNodeAt(peer, [0]) const gamma = getVisibleYjsNodeAt(peer, [2]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) replaceMiddleBlock(peer) - assert.deepEqual(getParagraphTexts(peer), ['alpha', 'bravo', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha', 'bravo', 'gamma']) assert.equal(getVisibleYjsNodeAt(peer, [0]), alpha) assert.equal(getVisibleYjsNodeAt(peer, [2]), gamma) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'replace_children' }, ]) - assertNoRootSnapshot(peer) }) it('reconnects, undoes, and redoes replace_children while preserving remote edits', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) replaceMiddleBlock(b) appendRemoteAlpha(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'bravo', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'bravo', 'gamma']) - assertNoRootSnapshot(b) }) it('preserves remote text when an offline replace_children is undone before reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) replaceFirstBlock(b) - assert.deepEqual(getParagraphTexts(b), ['bravo', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(b), ['bravo', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.undo()) - assert.deepEqual(getParagraphTexts(b), ['alpha', 'beta', 'gamma']) + undoYjsPeer(b) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha', 'beta', 'gamma']) appendRemoteAlpha(a) syncConnectedPeers(peers) - assert.deepEqual(getParagraphTexts(a), ['alpha!', 'beta', 'gamma']) - assert.deepEqual(getParagraphTexts(b), ['alpha', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!', 'beta', 'gamma']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha', 'beta', 'gamma']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!', 'beta', 'gamma']) - assertNoRootSnapshot(b) }) }) diff --git a/packages/slate-yjs/test/split-history-contract.spec.ts b/packages/slate-yjs/test/split-history-contract.spec.ts new file mode 100644 index 0000000000..ecf29ef657 --- /dev/null +++ b/packages/slate-yjs/test/split-history-contract.spec.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { isSplitHistory, type SplitHistory } from '../src/core/split-history' + +const splitHistory = (): SplitHistory => ({ + elementPath: [0], + elementPosition: 1, + elementProperties: { type: 'paragraph' }, + rightText: 'beta', + textPath: [0, 0], + textProperties: { bold: true }, +}) + +describe('split history contract', () => { + it('accepts complete split history metadata', () => { + assert.equal(isSplitHistory(splitHistory()), true) + assert.equal( + isSplitHistory({ + ...splitHistory(), + absorbedRemoteSplit: true, + undoneWhileDisconnected: false, + }), + true + ) + }) + + it('rejects malformed split history metadata', () => { + const invalid: readonly unknown[] = [ + { ...splitHistory(), absorbedRemoteSplit: 'true' }, + { ...splitHistory(), elementPath: ['0'] }, + { ...splitHistory(), elementPosition: -1 }, + { ...splitHistory(), elementPosition: Number.NaN }, + { ...splitHistory(), elementProperties: [] }, + { ...splitHistory(), textPath: [0, 0.5] }, + { ...splitHistory(), textProperties: null }, + { ...splitHistory(), undoneWhileDisconnected: 1 }, + ] + + for (const value of invalid) { + assert.equal(isSplitHistory(value), false) + } + }) +}) diff --git a/packages/slate-yjs/test/split-merge-contract.spec.ts b/packages/slate-yjs/test/split-merge-contract.spec.ts index 54881fdc99..ec8b52d6e9 100644 --- a/packages/slate-yjs/test/split-merge-contract.spec.ts +++ b/packages/slate-yjs/test/split-merge-contract.spec.ts @@ -3,15 +3,15 @@ import { describe, it } from 'node:test' import { type Descendant } from 'slate' import { Editor } from 'slate/internal' -import { readSlateValueFromYjs } from '../src/core/document' import { - assertNoRootSnapshot, + clearYjsTrace, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, - getYjsState, + getPeerTopLevelTexts, type Peer, - runYjsUpdate, + paragraph, + readPeerChildren, + readPeerSlateValue, syncConnectedPeers, } from './support/collaboration' @@ -21,41 +21,38 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const paragraphParts = (...texts: string[]): Descendant => ({ +const paragraphParts = (...texts: readonly string[]): Descendant => ({ type: 'paragraph', children: texts.map((text) => ({ text })), }) -const quote = (children: Descendant[]): Descendant => ({ +const quote = (children: readonly Descendant[]): Descendant => ({ type: 'quote', children, }) -const initialValue = () => [paragraph('Hello world!')] +const initialValue = (): Descendant[] => [paragraph('Hello world!')] const createPeer = ( - clientId: keyof typeof clientIds, - children = initialValue() -) => + clientId: ClientId, + children: readonly Descendant[] = initialValue() +): Peer => createYjsPeer({ children, clientId, numericClientId: clientIds[clientId], }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const splitThenDeleteBackwardEmptyParagraph = (peer: Peer) => { +const splitThenDeleteBackwardEmptyParagraph = (peer: Peer): void => { const textLength = Editor.string(peer.editor, [0]).length peer.editor.update((tx) => { @@ -81,13 +78,15 @@ const splitThenDeleteBackwardEmptyParagraph = (peer: Peer) => { }) } -const repeatSplitMerge = (peer: Peer, times: number) => { +const repeatSplitMerge = (peer: Peer, times: number): void => { for (let index = 0; index < times; index++) { splitThenDeleteBackwardEmptyParagraph(peer) } } -const assertNoLeakedVirtualPlaceholder = (nodes: readonly Descendant[]) => { +const assertNoLeakedVirtualPlaceholder = ( + nodes: readonly Descendant[] +): void => { for (const node of nodes) { if (!('children' in node)) { continue @@ -98,7 +97,7 @@ const assertNoLeakedVirtualPlaceholder = (nodes: readonly Descendant[]) => { } } -const assertNoNestedElements = (nodes: readonly Descendant[]) => { +const assertNoNestedElements = (nodes: readonly Descendant[]): void => { for (const node of nodes) { if (!('children' in node)) { continue @@ -120,33 +119,27 @@ describe('@slate/yjs split and merge collaboration contract', () => { syncConnectedPeers(peers) for (const peer of peers) { - const snapshotChildren = Editor.getSnapshot(peer.editor).children + const snapshotChildren = readPeerChildren(peer) - assert.deepEqual(getParagraphTexts(peer), ['Hello world!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello world!']) assertNoLeakedVirtualPlaceholder(snapshotChildren) assertNoNestedElements(snapshotChildren) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ - paragraph('Hello world!'), - ]) + assert.deepEqual(readPeerSlateValue(peer), [paragraph('Hello world!')]) } - assertNoRootSnapshot(a) }) it('keeps repeated local paragraph split and merge traceable', () => { const peer = createPeer('b') - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) repeatSplitMerge(peer, 2) - const snapshotChildren = Editor.getSnapshot(peer.editor).children + const snapshotChildren = readPeerChildren(peer) - assert.deepEqual(getParagraphTexts(peer), ['Hello world!']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello world!']) assertNoLeakedVirtualPlaceholder(snapshotChildren) assertNoNestedElements(snapshotChildren) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ - paragraph('Hello world!'), - ]) - assertNoRootSnapshot(peer) + assert.deepEqual(readPeerSlateValue(peer), [paragraph('Hello world!')]) }) it('keeps nested virtual placeholder content when splitting a parent element', () => { @@ -158,7 +151,7 @@ describe('@slate/yjs split and merge collaboration contract', () => { tx.nodes.merge({ at: [0, 2] }) }) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + assert.deepEqual(readPeerSlateValue(peer), [ quote([paragraph('intro'), paragraphParts('alpha', 'beta')]), ]) @@ -173,11 +166,10 @@ describe('@slate/yjs split and merge collaboration contract', () => { ]) }) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + assert.deepEqual(readPeerSlateValue(peer), [ quote([paragraph('intro')]), quote([paragraphParts('alpha', 'beta')]), ]) - assertNoRootSnapshot(peer) }) it('keeps parent-level virtual move content when merging the adopted target element', () => { @@ -197,7 +189,7 @@ describe('@slate/yjs split and merge collaboration contract', () => { ]) }) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + assert.deepEqual(readPeerSlateValue(peer), [ quote([paragraph('left')]), quote([paragraph('moved')]), ]) @@ -206,9 +198,8 @@ describe('@slate/yjs split and merge collaboration contract', () => { tx.nodes.merge({ at: [1] }) }) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + assert.deepEqual(readPeerSlateValue(peer), [ quote([paragraph('left'), paragraph('moved')]), ]) - assertNoRootSnapshot(peer) }) }) diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts index d20f241d44..7d834263ce 100644 --- a/packages/slate-yjs/test/split-node-contract.spec.ts +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -2,70 +2,74 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Descendant } from 'slate' import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { + getSlateYjsElementType, + setSlateYjsAttribute, +} from '../src/core/attributes' +import { createSplitElement } from '../src/core/replacement' import { - assertNoRootSnapshot, assertPeerTexts, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, getYjsNodeAt, - getYjsState, + getYjsTrace, type Peer, - runYjsUpdate, + paragraph, + redoYjsPeer, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeer, + undoYjsPeerAndSync, } from './support/collaboration' -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) - -const initialValue = () => [paragraph('alphabeta')] +const initialValue = (): Descendant[] => [paragraph('alphabeta')] -const helloValue = () => [paragraph('Hello world!')] +const helloValue = (): Descendant[] => [paragraph('Hello world!')] const createPeer = ( clientId: string, seedUpdate?: Uint8Array, - children = initialValue() + children: readonly Descendant[] = initialValue() ): Peer => createYjsPeer({ children, clientId, seedUpdate }) -const createPeers = (clientIds: string[], children = initialValue()) => { +const createPeers = ( + clientIds: readonly string[], + children: readonly Descendant[] = initialValue() +): Peer[] => { return createSeededYjsPeers({ children, clientIds }) } -const yjsState = getYjsState -const yjsUpdate = runYjsUpdate -const paragraphTexts = getParagraphTexts -const yjsNodeAt = getYjsNodeAt -const syncConnected = syncConnectedPeers -const assertAllTexts = assertPeerTexts - -const splitParagraph = (peer: Peer) => { +const splitParagraph = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.split({ at: { path: [0, 0], offset: 'alph'.length } }) }) } -const splitHelloParagraph = (peer: Peer) => { +const splitHelloParagraph = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.split({ at: { path: [0, 0], offset: 'Hello '.length } }) }) } -const insertRemoteTextAtSplitPoint = (peer: Peer) => { +const insertRemoteTextAtSplitPoint = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alph'.length } }) }) } -const appendRemoteText = (peer: Peer) => { +const appendRemoteText = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alphabeta'.length } }) }) } -const appendExclamationToFirstParagraph = (peer: Peer) => { +const appendExclamationToFirstParagraph = (peer: Peer): void => { const offset = Editor.string(peer.editor, [0]).length peer.editor.update((tx) => { @@ -73,7 +77,7 @@ const appendExclamationToFirstParagraph = (peer: Peer) => { }) } -const insertWorldParagraphAfterFirst = (peer: Peer) => { +const insertWorldParagraphAfterFirst = (peer: Peer): void => { const offset = Editor.string(peer.editor, [0]).length peer.editor.update((tx) => { @@ -90,7 +94,7 @@ const insertWorldParagraphAfterFirst = (peer: Peer) => { }) } -const insertTextSplitAndInsertRightText = (peer: Peer) => { +const insertTextSplitAndInsertRightText = (peer: Peer): void => { peer.editor.update((tx) => { tx.selection.set({ anchor: { path: [0, 0], offset: 0 }, @@ -109,167 +113,165 @@ const insertTextSplitAndInsertRightText = (peer: Peer) => { } describe('@slate/yjs split_node collaboration contract', () => { + it('keeps the original element type when split properties carry a non-string type', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const original = new Y.XmlElement('paragraph') + + root.insert(0, [original]) + setSlateYjsAttribute(original, 'type', 'paragraph') + + const right = createSplitElement(original, { role: 'note', type: 123 }, []) + + root.insert(1, [right]) + + assert.equal(getSlateYjsElementType(right), 'paragraph') + assert.equal(right.getAttribute('role'), 'note') + }) + it('applies local offline public split without a root snapshot fallback', () => { const peer = createPeer('b') - const leftText = yjsNodeAt(peer, [0, 0]) + const leftText = getYjsNodeAt(peer, [0, 0]) - yjsUpdate(peer, (yjs) => yjs.disconnect()) - yjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) splitParagraph(peer) - assert.deepEqual(paragraphTexts(peer), ['alph', 'abeta']) - assert.equal(yjsNodeAt(peer, [0, 0]), leftText) - assert.deepEqual(yjsState(peer).trace(), [ + assert.deepEqual(getPeerTopLevelTexts(peer), ['alph', 'abeta']) + assert.equal(getYjsNodeAt(peer, [0, 0]), leftText) + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'split_node' }, { mode: 'operation', operationType: 'split_node' }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote insert intent when an offline public split reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) splitParagraph(b) insertRemoteTextAtSplitPoint(a) - syncConnected(peers) + syncConnectedPeers(peers) - assert.deepEqual(paragraphTexts(a), ['alph!abeta']) - assert.deepEqual(paragraphTexts(b), ['alph', 'abeta']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alph!abeta']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alph', 'abeta']) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alph!', 'abeta']) - assertNoRootSnapshot(b) + assertPeerTexts(peers, ['alph!', 'abeta']) }) it('recovers split convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) splitParagraph(b) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(b, peers) - assertAllTexts(peers, ['alph', 'abeta']) + assertPeerTexts(peers, ['alph', 'abeta']) }) it('preserves a remote split when an offline local split was undone before reconnect', () => { const peers = createPeers(['a', 'b', 'c'], helloValue()) const [a, b] = peers - yjsUpdate(a, (yjs) => yjs.disconnect()) + disconnectYjsPeer(a) splitHelloParagraph(a) - yjsUpdate(a, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(a), ['Hello world!']) + undoYjsPeer(a) + assert.deepEqual(getPeerTopLevelTexts(a), ['Hello world!']) splitHelloParagraph(b) - syncConnected(peers) - assert.deepEqual(paragraphTexts(a), ['Hello world!']) - assert.deepEqual(paragraphTexts(b), ['Hello ', 'world!']) + syncConnectedPeers(peers) + assert.deepEqual(getPeerTopLevelTexts(a), ['Hello world!']) + assert.deepEqual(getPeerTopLevelTexts(b), ['Hello ', 'world!']) - yjsUpdate(a, (yjs) => yjs.connect()) - syncConnected(peers) + connectYjsPeerAndSync(a, peers) - assertAllTexts(peers, ['Hello ', 'world!']) - assertNoRootSnapshot(a) + assertPeerTexts(peers, ['Hello ', 'world!']) }) it('replays an offline split redo onto the remote split boundary after reconnect', () => { const peers = createPeers(['a', 'b', 'c'], helloValue()) const [a, b] = peers - yjsUpdate(a, (yjs) => yjs.disconnect()) + disconnectYjsPeer(a) splitHelloParagraph(a) - yjsUpdate(a, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(a), ['Hello world!']) + undoYjsPeer(a) + assert.deepEqual(getPeerTopLevelTexts(a), ['Hello world!']) appendExclamationToFirstParagraph(b) - syncConnected(peers) + syncConnectedPeers(peers) splitHelloParagraph(b) - syncConnected(peers) - assert.deepEqual(paragraphTexts(a), ['Hello world!']) - assert.deepEqual(paragraphTexts(b), ['Hello ', 'world!!']) + syncConnectedPeers(peers) + assert.deepEqual(getPeerTopLevelTexts(a), ['Hello world!']) + assert.deepEqual(getPeerTopLevelTexts(b), ['Hello ', 'world!!']) - yjsUpdate(a, (yjs) => yjs.connect()) - syncConnected(peers) - yjsUpdate(a, (yjs) => yjs.redo()) - syncConnected(peers) + connectYjsPeerAndSync(a, peers) + redoYjsPeerAndSync(a, peers) - assertAllTexts(peers, ['Hello ', 'world!!']) - assertNoRootSnapshot(a) + assertPeerTexts(peers, ['Hello ', 'world!!']) }) it('does not absorb a later unrelated paragraph that matches the offline undo suffix', () => { const peers = createPeers(['a', 'b', 'c'], helloValue()) const [a, b] = peers - yjsUpdate(a, (yjs) => yjs.disconnect()) + disconnectYjsPeer(a) splitHelloParagraph(a) - yjsUpdate(a, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(a), ['Hello world!']) + undoYjsPeer(a) + assert.deepEqual(getPeerTopLevelTexts(a), ['Hello world!']) - yjsUpdate(a, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['Hello world!']) + connectYjsPeerAndSync(a, peers) + assertPeerTexts(peers, ['Hello world!']) insertWorldParagraphAfterFirst(b) - syncConnected(peers) - assertAllTexts(peers, ['Hello world!', 'world! after']) + syncConnectedPeers(peers) + assertPeerTexts(peers, ['Hello world!', 'world! after']) - yjsUpdate(a, (yjs) => yjs.redo()) - syncConnected(peers) + redoYjsPeerAndSync(a, peers) - assertAllTexts(peers, ['Hello ', 'world!', 'world! after']) - assertNoRootSnapshot(a) + assertPeerTexts(peers, ['Hello ', 'world!', 'world! after']) }) it('undoes and redoes only the local split intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) splitParagraph(b) insertRemoteTextAtSplitPoint(a) - syncConnected(peers) + syncConnectedPeers(peers) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['alph!', 'abeta']) + connectYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alph!', 'abeta']) - yjsUpdate(b, (yjs) => yjs.undo()) - syncConnected(peers) - assertAllTexts(peers, ['alph!abeta']) + undoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alph!abeta']) - yjsUpdate(b, (yjs) => yjs.redo()) - syncConnected(peers) - assertAllTexts(peers, ['alph!', 'abeta']) - assertNoRootSnapshot(b) + redoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alph!', 'abeta']) }) it('redoes text inserted into a split-created paragraph after undoing to an empty document', () => { const peer = createPeer('b', undefined, [paragraph('')]) insertTextSplitAndInsertRightText(peer) - assert.deepEqual(paragraphTexts(peer), ['a', 'b']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['a', 'b']) - yjsUpdate(peer, (yjs) => yjs.undo()) - yjsUpdate(peer, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(peer), ['a']) + undoYjsPeer(peer) + undoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['a']) - yjsUpdate(peer, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(peer), ['']) + undoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['']) - yjsUpdate(peer, (yjs) => yjs.redo()) - assert.deepEqual(paragraphTexts(peer), ['a']) + redoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['a']) - yjsUpdate(peer, (yjs) => yjs.redo()) - yjsUpdate(peer, (yjs) => yjs.redo()) - assert.deepEqual(paragraphTexts(peer), ['a', 'b']) - assertNoRootSnapshot(peer) + redoYjsPeer(peer) + redoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['a', 'b']) }) it('undoes a split after a prior merge without custom split-history replay', () => { @@ -281,7 +283,7 @@ describe('@slate/yjs split_node collaboration contract', () => { peer.editor.update((tx) => { tx.nodes.merge({ at: [1] }) }) - assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello world!block 2']) peer.editor.update((tx) => { tx.operations.replay([ @@ -299,11 +301,10 @@ describe('@slate/yjs split_node collaboration contract', () => { }, ]) }) - assert.deepEqual(paragraphTexts(peer), ['Hello wor', 'ld!block 2']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello wor', 'ld!block 2']) - yjsUpdate(peer, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) - assertNoRootSnapshot(peer) + undoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello world!block 2']) }) it('undoes a break split after a prior merge without leaving the right split node visible', () => { @@ -315,7 +316,7 @@ describe('@slate/yjs split_node collaboration contract', () => { peer.editor.update((tx) => { tx.nodes.merge({ at: [1] }) }) - assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello world!block 2']) peer.editor.update((tx) => { tx.selection.set({ @@ -324,33 +325,28 @@ describe('@slate/yjs split_node collaboration contract', () => { }) tx.break.insert() }) - assert.deepEqual(paragraphTexts(peer), ['Hello wor', 'ld!block 2']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello wor', 'ld!block 2']) - yjsUpdate(peer, (yjs) => yjs.undo()) - assert.deepEqual(paragraphTexts(peer), ['Hello world!block 2']) - assertNoRootSnapshot(peer) + undoYjsPeer(peer) + assert.deepEqual(getPeerTopLevelTexts(peer), ['Hello world!block 2']) }) it('undoes an offline public split after a concurrent remote append', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - yjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) splitParagraph(b) appendRemoteText(a) - syncConnected(peers) + syncConnectedPeers(peers) - yjsUpdate(b, (yjs) => yjs.connect()) - syncConnected(peers) - assertAllTexts(peers, ['alph!', 'abeta']) + connectYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alph!', 'abeta']) - yjsUpdate(b, (yjs) => yjs.undo()) - syncConnected(peers) - assertAllTexts(peers, ['alph!abeta']) + undoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alph!abeta']) - yjsUpdate(b, (yjs) => yjs.redo()) - syncConnected(peers) - assertAllTexts(peers, ['alph!', 'abeta']) - assertNoRootSnapshot(b) + redoYjsPeerAndSync(b, peers) + assertPeerTexts(peers, ['alph!', 'abeta']) }) }) diff --git a/packages/slate-yjs/test/structural-soak-contract.spec.ts b/packages/slate-yjs/test/structural-soak-contract.spec.ts index 08280d806d..e929a6184f 100644 --- a/packages/slate-yjs/test/structural-soak-contract.spec.ts +++ b/packages/slate-yjs/test/structural-soak-contract.spec.ts @@ -1,71 +1,79 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import type { Descendant, Operation } from 'slate' -import { Editor } from 'slate/internal' +import type { Descendant, Operation, Path } from 'slate' import * as Y from 'yjs' -import { readSlateValueFromYjs } from '../src/core/document' import { applySlateOperationToYjs } from '../src/core/operations' import type { Peer } from './support/collaboration' import { createSeededYjsPeers, createYjsPeer, FakeAwareness, - getYjsState, + getPeerTopLevelTexts, + getYjsRoot, + isYjsPeerConnected, + paragraph, + readPeerChildren, + readPeerSlateValue, + reconcileYjsPeer, + redoYjsPeer, runYjsUpdate, syncConnectedPeers, + undoYjsPeer, } from './support/collaboration' -type PeerId = 'a' | 'b' | 'c' | 'd' +const peerIds = ['a', 'b', 'c', 'd'] as const -const clientIds: Record = { +type PeerId = (typeof peerIds)[number] +type SoakPeers = Record + +const clientIds: Readonly> = { a: 101, b: 202, c: 303, d: 404, } -const appendTexts: Record = { +const appendTexts: Readonly> = { a: ' Ada', b: ' Lin', c: ' Ken', d: ' Eve', } -const fragmentTexts: Record = { +const fragmentTexts: Readonly> = { a: 'Ada fragment', b: 'Lin fragment', c: 'Ken fragment', d: 'Eve fragment', } -const replacementTexts: Record = { +const replacementTexts: Readonly> = { a: 'Ada canonical snapshot.', b: 'Lin canonical snapshot.', c: 'Ken canonical snapshot.', d: 'Eve canonical snapshot.', } -const paragraph = (text: string): Descendant => ({ - children: [{ text }], - type: 'paragraph', -}) - -const initialValue = () => [paragraph('Hello world!')] +const initialValue = (): Descendant[] => [paragraph('Hello world!')] -const createPeers = () => { +const createPeers = (): SoakPeers => { const peers = createSeededYjsPeers({ children: initialValue(), - clientIds: ['a', 'b', 'c', 'd'], + clientIds: [...peerIds], numericClientIds: clientIds, }) - return Object.fromEntries( - (['a', 'b', 'c', 'd'] as const).map((id, index) => [id, peers[index]!]) - ) as Record + const [a, b, c, d] = peers + + if (!a || !b || !c || !d) { + throw new Error('Expected four structural soak peers.') + } + + return { a, b, c, d } } -const createAwarePeers = () => { +const createAwarePeers = (): SoakPeers => { const first = createYjsPeer({ awareness: new FakeAwareness(clientIds.a), children: initialValue(), @@ -101,15 +109,15 @@ const createAwarePeers = () => { return peers } -const allPeers = (peers: Record) => - ['a', 'b', 'c', 'd'].map((id) => peers[id as PeerId]) +const allPeers = (peers: Readonly>): Peer[] => + peerIds.map((id) => peers[id]) -const editorValueOf = (peer: Peer) => - Editor.getSnapshot(peer.editor).children as Descendant[] +const editorValueOf = (peer: Peer): readonly Descendant[] => + readPeerChildren(peer) type TextEntry = { - path: number[] - text: string + readonly path: Path + readonly text: string } const isText = (node: Descendant): node is Descendant & { text: string } => @@ -122,7 +130,7 @@ const hasChildren = ( const findTextEntryInNode = ( node: Descendant, - path: number[], + path: Path, direction: 'first' | 'last' ): TextEntry | null => { if (isText(node)) { @@ -140,13 +148,13 @@ const findTextEntryInNode = ( for (let index = start; index !== end; index += step) { const child = node.children[index] - if (!child) { + if (child === undefined) { continue } const entry = findTextEntryInNode(child, [...path, index], direction) - if (entry) { + if (entry !== null) { return entry } } @@ -154,30 +162,30 @@ const findTextEntryInNode = ( return null } -const firstBlockTextEntry = (peer: Peer, direction: 'first' | 'last') => { +const firstBlockTextEntry = ( + peer: Peer, + direction: 'first' | 'last' +): TextEntry | null => { const [block] = editorValueOf(peer) - return block ? findTextEntryInNode(block, [0], direction) : null + return block === undefined ? null : findTextEntryInNode(block, [0], direction) } -const topLevelCount = (peer: Peer) => editorValueOf(peer).length - -const paragraphTextsOf = (peer: Peer) => - editorValueOf(peer).map((_, index) => Editor.string(peer.editor, [index])) +const topLevelCount = (peer: Peer): number => editorValueOf(peer).length -const assertPeerParagraphTexts = ( +const assertPeerTopLevelTexts = ( peers: readonly Peer[], expected: readonly string[] -) => { +): void => { for (const peer of peers) { - assert.deepEqual(paragraphTextsOf(peer), expected) + assert.deepEqual(getPeerTopLevelTexts(peer), expected) } } const assertFirstParagraphTextChildren = ( peers: readonly Peer[], expected: readonly string[] -) => { +): void => { for (const peer of peers) { const [firstBlock] = editorValueOf(peer) @@ -189,19 +197,19 @@ const assertFirstParagraphTextChildren = ( JSON.stringify(editorValueOf(peer)) ) assert.deepEqual( - readSlateValueFromYjs(getYjsState(peer).root())[0]?.children.map( - (child) => child.text - ), + readPeerSlateValue(peer)[0]?.children.map((child) => child.text), expected ) } } -const firstBlockIsQuote = (peer: Peer) => { +const firstBlockIsQuote = (peer: Peer): boolean => { const [firstBlock] = editorValueOf(peer) return ( - !!firstBlock && 'type' in firstBlock && firstBlock.type === 'block-quote' + firstBlock !== undefined && + 'type' in firstBlock && + firstBlock.type === 'block-quote' ) } @@ -224,7 +232,7 @@ const hasNestedParagraph = ( ) } -const assertNoNestedParagraphs = (peers: readonly Peer[]) => { +const assertNoNestedParagraphs = (peers: readonly Peer[]): void => { for (const peer of peers) { const value = editorValueOf(peer) @@ -234,9 +242,7 @@ const assertNoNestedParagraphs = (peers: readonly Peer[]) => { JSON.stringify(value) ) assert.equal( - readSlateValueFromYjs(getYjsState(peer).root()).some((node) => - hasNestedParagraph(node) - ), + readPeerSlateValue(peer).some((node) => hasNestedParagraph(node)), false ) } @@ -261,10 +267,12 @@ const hasElementDescendantInsideParagraph = ( ) } -const assertNoElementDescendantsInsideParagraphs = (peers: readonly Peer[]) => { +const assertNoElementDescendantsInsideParagraphs = ( + peers: readonly Peer[] +): void => { for (const peer of peers) { const value = editorValueOf(peer) - const yjsValue = readSlateValueFromYjs(getYjsState(peer).root()) + const yjsValue = readPeerSlateValue(peer) assert.equal( value.some((node) => hasElementDescendantInsideParagraph(node)), @@ -283,7 +291,8 @@ const getNodeAtPath = ( children: readonly Descendant[], path: readonly number[] ): Descendant | null => { - let current: { children: readonly Descendant[] } | Descendant = { children } + const root = { children } + let current: typeof root | Descendant = root for (const index of path) { if (!hasChildren(current)) { @@ -292,24 +301,21 @@ const getNodeAtPath = ( const child = current.children[index] - if (!child) { + if (child === undefined) { return null } current = child } - return current as Descendant + return current === root ? null : current } -const assertSelectionsTargetText = (peers: readonly Peer[]) => { +const assertSelectionsTargetText = (peers: readonly Peer[]): void => { for (const peer of peers) { - const selection = peer.editor.read((state) => state.selection.get()) as { - anchor: { path: number[] } - focus: { path: number[] } - } | null + const selection = peer.editor.read((state) => state.selection.get()) - if (!selection) { + if (selection === null) { continue } @@ -319,7 +325,7 @@ const assertSelectionsTargetText = (peers: readonly Peer[]) => { const node = getNodeAtPath(value, point.path) assert.equal( - !!node && isText(node), + node !== null && isText(node), true, JSON.stringify({ selection, value }) ) @@ -327,44 +333,44 @@ const assertSelectionsTargetText = (peers: readonly Peer[]) => { } } -const sync = (peers: Record) => { +const sync = (peers: Readonly>): void => { const peerList = allPeers(peers) syncConnectedPeers(peerList) for (const peer of peerList) { - if (!getYjsState(peer).connected()) { + if (!isYjsPeerConnected(peer)) { continue } - runYjsUpdate(peer, (yjs) => yjs.reconcile()) + reconcileYjsPeer(peer) } } const runCommand = ( - peers: Record, + peers: Readonly>, peerId: PeerId, command: (peer: Peer, peerId: PeerId) => void -) => { +): void => { command(peers[peerId], peerId) sync(peers) } const runIncrementalCommand = ( - peers: Record, + peers: Readonly>, peerId: PeerId, command: (peer: Peer, peerId: PeerId) => void -) => { +): void => { const source = peers[peerId] const stateVector = Y.encodeStateVector(source.doc) command(source, peerId) - if (getYjsState(source).connected()) { + if (isYjsPeerConnected(source)) { const update = Y.encodeStateAsUpdate(source.doc, stateVector) for (const target of allPeers(peers)) { - if (source === target || !getYjsState(target).connected()) { + if (source === target || !isYjsPeerConnected(target)) { continue } @@ -373,29 +379,35 @@ const runIncrementalCommand = ( } for (const peer of allPeers(peers)) { - if (!getYjsState(peer).connected()) { + if (!isYjsPeerConnected(peer)) { continue } - runYjsUpdate(peer, (yjs) => yjs.reconcile()) + reconcileYjsPeer(peer) } } const setConnected = ( - peers: Record, + peers: Readonly>, peerId: PeerId, connected: boolean -) => { +): void => { runYjsUpdate(peers[peerId], (yjs) => connected ? yjs.connect() : yjs.disconnect() ) sync(peers) } -const appendText = (peer: Peer, peerId: PeerId) => { +const connectAll = (peers: Readonly>): void => { + for (const peerId of peerIds) { + setConnected(peers, peerId, true) + } +} + +const appendText = (peer: Peer, peerId: PeerId): void => { const entry = firstBlockTextEntry(peer, 'last') - if (!entry) { + if (entry === null) { return } @@ -406,7 +418,7 @@ const appendText = (peer: Peer, peerId: PeerId) => { }) } -const splitFirstText = (peer: Peer) => { +const splitFirstText = (peer: Peer): void => { const entry = firstBlockTextEntry(peer, 'first') if (!entry || entry.text.length < 2) { @@ -424,7 +436,7 @@ const splitFirstText = (peer: Peer) => { }) } -const ensureTopLevelCount = (peer: Peer, count: number) => { +const ensureTopLevelCount = (peer: Peer, count: number): void => { const current = topLevelCount(peer) if (current >= count) { @@ -438,7 +450,7 @@ const ensureTopLevelCount = (peer: Peer, count: number) => { }) } -const moveFirstBlockDown = (peer: Peer) => { +const moveFirstBlockDown = (peer: Peer): void => { ensureTopLevelCount(peer, 2) peer.editor.update((tx) => { @@ -446,7 +458,7 @@ const moveFirstBlockDown = (peer: Peer) => { }) } -const moveFirstBlockAfterSecond = (peer: Peer) => { +const moveFirstBlockAfterSecond = (peer: Peer): void => { if (topLevelCount(peer) < 2) { return } @@ -456,7 +468,7 @@ const moveFirstBlockAfterSecond = (peer: Peer) => { }) } -const mergeSecondBlock = (peer: Peer) => { +const mergeSecondBlock = (peer: Peer): void => { if (topLevelCount(peer) < 2) { return } @@ -466,7 +478,7 @@ const mergeSecondBlock = (peer: Peer) => { }) } -const removeSecondBlock = (peer: Peer) => { +const removeSecondBlock = (peer: Peer): void => { if (topLevelCount(peer) < 2) { return } @@ -476,7 +488,7 @@ const removeSecondBlock = (peer: Peer) => { }) } -const replaceDocument = (peer: Peer, peerId: PeerId) => { +const replaceDocument = (peer: Peer, peerId: PeerId): void => { const children = editorValueOf(peer) const text = replacementTexts[peerId] @@ -499,7 +511,7 @@ const replaceDocument = (peer: Peer, peerId: PeerId) => { }) } -const wrapFirstBlock = (peer: Peer) => { +const wrapFirstBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.selection.clear() tx.nodes.wrap({ children: [], type: 'block-quote' }, { at: [0] }) @@ -507,7 +519,7 @@ const wrapFirstBlock = (peer: Peer) => { }) } -const unwrapFirstBlock = (peer: Peer) => { +const unwrapFirstBlock = (peer: Peer): void => { if (!firstBlockIsQuote(peer)) { return } @@ -517,7 +529,7 @@ const unwrapFirstBlock = (peer: Peer) => { }) } -const liftFirstWrappedBlock = (peer: Peer) => { +const liftFirstWrappedBlock = (peer: Peer): void => { if (!firstBlockIsQuote(peer)) { return } @@ -527,7 +539,7 @@ const liftFirstWrappedBlock = (peer: Peer) => { }) } -const unsetFirstBlockRole = (peer: Peer) => { +const unsetFirstBlockRole = (peer: Peer): void => { const [firstBlock] = editorValueOf(peer) if (!firstBlock || !('role' in firstBlock)) { @@ -535,20 +547,20 @@ const unsetFirstBlockRole = (peer: Peer) => { } peer.editor.update((tx) => { - tx.nodes.unset('role' as never, { at: [0] }) + tx.nodes.unset('role', { at: [0] }) }) } -const setFirstBlockRole = (peer: Peer) => { +const setFirstBlockRole = (peer: Peer): void => { peer.editor.update((tx) => { - tx.nodes.set({ role: 'title' } as never, { at: [0] }) + tx.nodes.set({ role: 'title' }, { at: [0] }) }) } -const insertExclamation = (peer: Peer) => { +const insertExclamation = (peer: Peer): void => { const entry = firstBlockTextEntry(peer, 'last') - if (!entry) { + if (entry === null) { return } @@ -559,10 +571,10 @@ const insertExclamation = (peer: Peer) => { }) } -const insertPeerFragment = (peer: Peer, peerId: PeerId) => { +const insertPeerFragment = (peer: Peer, peerId: PeerId): void => { const entry = firstBlockTextEntry(peer, 'last') - if (!entry) { + if (entry === null) { return } @@ -575,10 +587,10 @@ const insertPeerFragment = (peer: Peer, peerId: PeerId) => { }) } -const deleteFirstFragment = (peer: Peer) => { +const deleteFirstFragment = (peer: Peer): void => { const entry = firstBlockTextEntry(peer, 'first') - if (!entry) { + if (entry === null) { return } @@ -597,7 +609,7 @@ const deleteFirstFragment = (peer: Peer) => { }) } -const deleteBackwardFromFirstBlockEnd = (peer: Peer) => { +const deleteBackwardFromFirstBlockEnd = (peer: Peer): void => { const entry = firstBlockTextEntry(peer, 'last') if (!entry || entry.text.length === 0) { @@ -613,22 +625,22 @@ const deleteBackwardFromFirstBlockEnd = (peer: Peer) => { }) } -const reconcilePeer = (peer: Peer) => { - runYjsUpdate(peer, (yjs) => yjs.reconcile()) +const reconcilePeer = (peer: Peer): void => { + reconcileYjsPeer(peer) } -const undoPeer = (peer: Peer) => { - runYjsUpdate(peer, (yjs) => yjs.undo()) +const undoPeer = (peer: Peer): void => { + undoYjsPeer(peer) } -const redoPeer = (peer: Peer) => { - runYjsUpdate(peer, (yjs) => yjs.redo()) +const redoPeer = (peer: Peer): void => { + redoYjsPeer(peer) } -const toggleFirstBlockBold = (peer: Peer) => { +const toggleFirstBlockBold = (peer: Peer): void => { const entry = firstBlockTextEntry(peer, 'first') - if (!entry) { + if (entry === null) { return } @@ -643,7 +655,7 @@ const toggleFirstBlockBold = (peer: Peer) => { }) } -const assertDocumentHasTextBoundary = (peers: readonly Peer[]) => { +const assertDocumentHasTextBoundary = (peers: readonly Peer[]): void => { for (const peer of peers) { const value = editorValueOf(peer) @@ -685,12 +697,12 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'b', unwrapFirstBlock) assertNoNestedParagraphs(allPeers(peers)) - assertPeerParagraphTexts([peers.a], ['Hello world!', 'block 2']) - assertPeerParagraphTexts([peers.b, peers.c, peers.d], ['Hello world!']) + assertPeerTopLevelTexts([peers.a], ['Hello world!', 'block 2']) + assertPeerTopLevelTexts([peers.b, peers.c, peers.d], ['Hello world!']) setConnected(peers, 'a', true) - assertPeerParagraphTexts(allPeers(peers), ['Hello world!', 'block 2']) + assertPeerTopLevelTexts(allPeers(peers), ['Hello world!', 'block 2']) }) it('exports selection after structural unwrap only when the Yjs target is text', () => { @@ -728,15 +740,15 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'c', unwrapFirstBlock) }) assertNoNestedParagraphs(allPeers(peers)) - assertPeerParagraphTexts( + assertPeerTopLevelTexts( [peers.a, peers.b, peers.c], ['Hello wo', 'rld!! Lin!'] ) - assertPeerParagraphTexts([peers.d], ['Hello world!! Lin!']) + assertPeerTopLevelTexts([peers.d], ['Hello world!! Lin!']) setConnected(peers, 'd', true) - assertPeerParagraphTexts(allPeers(peers), ['Hello wo', 'rld!! Lin!']) + assertPeerTopLevelTexts(allPeers(peers), ['Hello wo', 'rld!! Lin!']) }) it('keeps remote wrap after lift from duplicating the wrapped block', () => { @@ -746,7 +758,7 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'c', liftFirstWrappedBlock) runCommand(peers, 'a', wrapFirstBlock) - assertPeerParagraphTexts(allPeers(peers), ['Hello world!']) + assertPeerTopLevelTexts(allPeers(peers), ['Hello world!']) assertNoElementDescendantsInsideParagraphs(allPeers(peers)) }) @@ -755,14 +767,10 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'b', wrapFirstBlock) runCommand(peers, 'c', moveFirstBlockDown) - runCommand(peers, 'd', (peer) => { - peer.editor.update((tx) => { - tx.nodes.set({ role: 'title' } as never, { at: [0] }) - }) - }) + runCommand(peers, 'd', setFirstBlockRole) runCommand(peers, 'a', moveFirstBlockDown) - assertPeerParagraphTexts(allPeers(peers), ['Hello world!', 'block 2']) + assertPeerTopLevelTexts(allPeers(peers), ['Hello world!', 'block 2']) assertNoElementDescendantsInsideParagraphs(allPeers(peers)) }) @@ -783,13 +791,10 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'c', unsetFirstBlockRole) runCommand(peers, 'd', deleteFirstFragment) runCommand(peers, 'd', mergeSecondBlock) - setConnected(peers, 'a', true) - setConnected(peers, 'b', true) - setConnected(peers, 'c', true) - setConnected(peers, 'd', true) + connectAll(peers) runCommand(peers, 'a', reconcilePeer) - assertPeerParagraphTexts(allPeers(peers), ['!Ken fragmenHello ']) + assertPeerTopLevelTexts(allPeers(peers), ['!Ken fragmenHello ']) }) it('keeps offline structural mix seed 99 from retaining a zero-width prefix', () => { @@ -804,14 +809,10 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'd', splitFirstText) runCommand(peers, 'b', mergeSecondBlock) runCommand(peers, 'c', splitFirstText) - setConnected(peers, 'b', true) - setConnected(peers, 'a', true) - setConnected(peers, 'b', true) - setConnected(peers, 'c', true) - setConnected(peers, 'd', true) + connectAll(peers) runCommand(peers, 'a', reconcilePeer) - assertPeerParagraphTexts(allPeers(peers), [ + assertPeerTopLevelTexts(allPeers(peers), [ 'block 2', 'llo', ' Ken', @@ -837,13 +838,10 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'a', setFirstBlockRole) runCommand(peers, 'c', replaceDocument) runCommand(peers, 'c', unsetFirstBlockRole) - setConnected(peers, 'a', true) - setConnected(peers, 'b', true) - setConnected(peers, 'c', true) - setConnected(peers, 'd', true) + connectAll(peers) runCommand(peers, 'a', reconcilePeer) - assertPeerParagraphTexts(allPeers(peers), ['Ken canonical snapshot.']) + assertPeerTopLevelTexts(allPeers(peers), ['Ken canonical snapshot.']) }) it('keeps random-control seed 131 empty trailing block converged', () => { @@ -863,13 +861,10 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'a', toggleFirstBlockBold) runCommand(peers, 'b', mergeSecondBlock) runCommand(peers, 'b', splitFirstText) - setConnected(peers, 'a', true) - setConnected(peers, 'b', true) - setConnected(peers, 'c', true) - setConnected(peers, 'd', true) + connectAll(peers) runCommand(peers, 'a', reconcilePeer) - assertPeerParagraphTexts(allPeers(peers), ['n', ' canonical snapshot.K']) + assertPeerTopLevelTexts(allPeers(peers), ['n', ' canonical snapshot.K']) }) it('keeps structural edits from projecting block placeholders inside paragraphs', () => { @@ -877,11 +872,7 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'a', splitFirstText) runCommand(peers, 'c', deleteFirstFragment) - runCommand(peers, 'c', (peer) => { - peer.editor.update((tx) => { - tx.nodes.set({ role: 'title' } as never, { at: [0] }) - }) - }) + runCommand(peers, 'c', setFirstBlockRole) reconcilePeer(peers.d) runCommand(peers, 'd', deleteBackwardFromFirstBlockEnd) setConnected(peers, 'a', true) @@ -968,7 +959,7 @@ describe('@slate/yjs structural soak contract', () => { runCommand(peers, 'b', (peer, peerId) => { const entry = firstBlockTextEntry(peer, 'last') - if (!entry) { + if (entry === null) { return } @@ -995,11 +986,7 @@ describe('@slate/yjs structural soak contract', () => { const peers = createAwarePeers() setConnected(peers, 'b', false) - runCommand(peers, 'b', (peer) => { - peer.editor.update((tx) => { - tx.nodes.set({ role: 'title' } as never, { at: [0] }) - }) - }) + runCommand(peers, 'b', setFirstBlockRole) runCommand(peers, 'a', mergeSecondBlock) runCommand(peers, 'b', wrapFirstBlock) runCommand(peers, 'a', insertExclamation) @@ -1047,14 +1034,11 @@ describe('@slate/yjs structural soak contract', () => { type: 'move_node', } - assert.deepEqual( - applySlateOperationToYjs(getYjsState(peer).root(), operation), - { - fallback: 'missing-move-source-elided', - mode: 'traceable-fallback', - operationType: 'move_node', - } - ) + assert.deepEqual(applySlateOperationToYjs(getYjsRoot(peer), operation), { + fallback: 'missing-move-source-elided', + mode: 'traceable-fallback', + operationType: 'move_node', + }) }) it('keeps offline structural mix seed 16 from losing root text boundaries', () => { @@ -1073,7 +1057,7 @@ describe('@slate/yjs structural soak contract', () => { assertNoNestedParagraphs(allPeers(peers)) assertDocumentHasTextBoundary(allPeers(peers)) - assertPeerParagraphTexts(allPeers(peers), ['', 'l', 'o ', '', 'world!']) + assertPeerTopLevelTexts(allPeers(peers), ['', 'l', 'o ', '', 'world!']) }) it('keeps remote wrap and unwrap from dropping split-merge text prefixes', () => { @@ -1088,25 +1072,16 @@ describe('@slate/yjs structural soak contract', () => { runIncrementalCommand(peers, 'c', splitFirstText) runIncrementalCommand(peers, 'd', mergeSecondBlock) - assertPeerParagraphTexts(allPeers(peers), [ - 'Hello Ada! Ada Ada!', - 'world!', - ]) + assertPeerTopLevelTexts(allPeers(peers), ['Hello Ada! Ada Ada!', 'world!']) runIncrementalCommand(peers, 'a', wrapFirstBlock) - assertPeerParagraphTexts(allPeers(peers), [ - 'Hello Ada! Ada Ada!', - 'world!', - ]) + assertPeerTopLevelTexts(allPeers(peers), ['Hello Ada! Ada Ada!', 'world!']) assertNoElementDescendantsInsideParagraphs(allPeers(peers)) runIncrementalCommand(peers, 'b', unwrapFirstBlock) - assertPeerParagraphTexts(allPeers(peers), [ - 'Hello Ada! Ada Ada!', - 'world!', - ]) + assertPeerTopLevelTexts(allPeers(peers), ['Hello Ada! Ada Ada!', 'world!']) assertNoElementDescendantsInsideParagraphs(allPeers(peers)) }) }) diff --git a/packages/slate-yjs/test/support/collaboration.ts b/packages/slate-yjs/test/support/collaboration.ts index a70da5bcd9..8aa56175d3 100644 --- a/packages/slate-yjs/test/support/collaboration.ts +++ b/packages/slate-yjs/test/support/collaboration.ts @@ -1,93 +1,72 @@ import assert from 'node:assert/strict' -import { createEditor, type Descendant } from 'slate' +import { + createEditor, + type Descendant, + defineEditorExtension, + type EditorCommitContext, + type EditorExtensionSetupOutput, + type Operation, + type Range, + type Selection, + type Editor as SlateEditor, +} from 'slate' import { Editor } from 'slate/internal' +import type {} from 'slate-history' import * as Y from 'yjs' import { createYjsExtension } from '../../src' -import { getYjsNode } from '../../src/core/document' +import type { YjsNode } from '../../src/core/attributes' +import { getYjsNode, readSlateValueFromYjs } from '../../src/core/document' +import { getEditorYjsState, getEditorYjsTx } from '../../src/core/editor-yjs' import type { - YjsAwarenessChange, YjsAwarenessLike, YjsProviderLike, + YjsProviderStatus, + YjsRemoteCursor, + YjsRemoteCursorData, YjsState, + YjsTraceEntry, YjsTx, } from '../../src/core/types' -export type Peer = { - cleanup: () => void - doc: Y.Doc - editor: ReturnType -} +export { FakeAwareness, FakeProvider } from './provider' -type YjsStateView = { - yjs: YjsState +type TestEditorDomApi = { + readonly isFocused?: () => boolean + readonly resolveRangeRect?: (range: Range) => unknown } -type YjsTxView = { - yjs: YjsTx -} - -export class FakeAwareness implements YjsAwarenessLike { - readonly clientID: number - readonly doc: { clientID: number } - - private readonly listeners = new Set<(event: YjsAwarenessChange) => void>() - private localState: Record | null = null - private readonly states = new Map>() - - constructor(clientID: number) { - this.clientID = clientID - this.doc = { clientID } - } - - getLocalState() { - return this.localState - } - - getStates() { - return this.states - } - - off(event: 'change', handler: (event: YjsAwarenessChange) => void) { - if (event === 'change') { - this.listeners.delete(handler) - } - } - - on(event: 'change', handler: (event: YjsAwarenessChange) => void) { - if (event === 'change') { - this.listeners.add(handler) - } +type TestEditor = SlateEditor & { + api?: { + dom?: TestEditorDomApi } +} - removeRemoteState(clientId: number) { - this.states.delete(clientId) - this.emit({ added: [], removed: [clientId], updated: [] }) - } +export type Peer = { + readonly cleanup: () => void + readonly doc: Y.Doc + readonly editor: TestEditor +} - setLocalStateField(field: string, value: unknown) { - this.localState = { - ...(this.localState ?? {}), - [field]: value, - } - this.states.set(this.clientID, this.localState) - this.emit({ added: [], removed: [], updated: [this.clientID] }) - } +type OperationTypeRecorderOptions = { + readonly name: string + readonly shouldRecord?: (context: EditorCommitContext) => boolean +} - setRemoteState(clientId: number, state: Record) { - const added = this.states.has(clientId) ? [] : [clientId] - const updated = this.states.has(clientId) ? [clientId] : [] +export const paragraph = ( + text: string, + attributes: Readonly> = {} +): Descendant => ({ + ...attributes, + children: [{ text }], + type: 'paragraph', +}) - this.states.set(clientId, state) - this.emit({ added, removed: [], updated }) - } +const isYjsNode = (value: unknown): value is YjsNode => + value instanceof Y.XmlElement || value instanceof Y.XmlText - private emit(event: YjsAwarenessChange) { - for (const listener of this.listeners) { - listener(event) - } - } -} +const getRawYjsChildren = (node: Y.XmlElement): YjsNode[] => + node.toArray().filter(isYjsNode) export const createYjsPeer = ({ children, @@ -98,16 +77,16 @@ export const createYjsPeer = ({ seedUpdate, }: { awareness?: YjsAwarenessLike - children: Descendant[] + children: readonly Descendant[] clientId: string numericClientId?: number provider?: YjsProviderLike seedUpdate?: Uint8Array }): Peer => { - const editor = createEditor() + const editor: TestEditor = createEditor() Editor.replace(editor, { - children, + children: [...children], marks: null, selection: null, }) @@ -118,7 +97,7 @@ export const createYjsPeer = ({ doc.clientID = numericClientId } - if (seedUpdate) { + if (seedUpdate !== undefined) { Y.applyUpdate(doc, seedUpdate) } @@ -140,13 +119,13 @@ export const createSeededYjsPeers = ({ clientIds, numericClientIds, }: { - children: Descendant[] - clientIds: string[] - numericClientIds?: Record -}) => { + children: readonly Descendant[] + clientIds: readonly string[] + numericClientIds?: Readonly> +}): Peer[] => { const [firstClientId, ...remainingClientIds] = clientIds - if (!firstClientId) { + if (firstClientId === undefined) { return [] } @@ -170,30 +149,31 @@ export const createSeededYjsPeers = ({ ] } -export const getParagraphTexts = (peer: Peer) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) +export const readPeerChildren = (peer: Peer): readonly Descendant[] => + Editor.getSnapshot(peer.editor).children + +export const readPeerSelection = (peer: Peer): Selection => + Editor.getSnapshot(peer.editor).selection + +export const getPeerTopLevelTexts = (peer: Peer): string[] => + readPeerChildren(peer).map((_, index) => Editor.string(peer.editor, [index])) + +export const getPeerTopLevelTypes = (peer: Peer): string[] => + readPeerChildren(peer).map((node) => + 'type' in node ? String(node.type) : 'text' ) -export const getYjsNodeAt = ( - peer: Peer, - path: number[] -): Y.XmlElement | Y.XmlText => { - let current: Y.XmlElement | Y.XmlText = getYjsState(peer).root() +export const getYjsNodeAt = (peer: Peer, path: readonly number[]): YjsNode => { + let current: YjsNode = getYjsRoot(peer) for (const index of path) { if (current instanceof Y.XmlText) { throw new Error(`Cannot descend into Y.XmlText at ${path.join('.')}`) } - const child = current - .toArray() - .filter( - (value): value is Y.XmlElement | Y.XmlText => - value instanceof Y.XmlElement || value instanceof Y.XmlText - )[index] + const child = getRawYjsChildren(current)[index] - if (!child) { + if (child === undefined) { throw new Error(`No Yjs node at ${path.join('.')}`) } @@ -205,28 +185,154 @@ export const getYjsNodeAt = ( export const getVisibleYjsNodeAt = ( peer: Peer, - path: number[] -): Y.XmlElement | Y.XmlText => getYjsNode(getYjsState(peer).root(), path) + path: readonly number[] +): YjsNode => getYjsNode(getYjsRoot(peer), path) + +export const readEditorYjsState = (editor: TestEditor): YjsState => + editor.read(getEditorYjsState) + +export const getYjsState = (peer: Peer): YjsState => + readEditorYjsState(peer.editor) + +export const getYjsRoot = (peer: Peer): Y.XmlElement => getYjsState(peer).root() + +export const getYjsTrace = (peer: Peer): readonly YjsTraceEntry[] => + getYjsState(peer).trace() + +export const getYjsRemoteCursors = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, +>( + peer: Peer +): readonly YjsRemoteCursor[] => + getYjsState(peer).remoteCursors() + +export const getYjsAwarenessRevision = (peer: Peer): number => + getYjsState(peer).awarenessRevision() + +export const getYjsProviderStatus = (peer: Peer): YjsProviderStatus | null => + getYjsState(peer).providerStatus() + +export const getYjsProviderSynced = (peer: Peer): boolean | null => + getYjsState(peer).providerSynced() -export const getYjsState = (peer: Peer) => - peer.editor.read((state) => (state as YjsStateView).yjs) +export const isYjsPeerConnected = (peer: Peer): boolean => + getYjsState(peer).connected() -export const runYjsUpdate = (peer: Peer, fn: (tx: YjsTx) => void) => { - peer.editor.update((tx) => { - fn((tx as YjsTxView).yjs) +export const subscribeYjsAwareness = ( + peer: Peer, + listener: () => void +): (() => void) => getYjsState(peer).subscribeAwareness(listener) + +export const readPeerSlateValue = (peer: Peer): Descendant[] => + readSlateValueFromYjs(getYjsRoot(peer)) + +export const runEditorYjsUpdate = ( + editor: TestEditor, + fn: (tx: YjsTx) => void +): void => { + editor.update((tx) => { + fn(getEditorYjsTx(tx)) }) } -export const syncConnectedPeers = (peers: Peer[]) => { +export const runYjsUpdate = (peer: Peer, fn: (tx: YjsTx) => void): void => { + runEditorYjsUpdate(peer.editor, fn) +} + +export const recordEditorOperationTypes = ( + editor: TestEditor, + { name, shouldRecord }: OperationTypeRecorderOptions +): Operation['type'][] => { + const operationTypes: Operation['type'][] = [] + + editor.extend( + defineEditorExtension({ + name, + setup(): EditorExtensionSetupOutput { + return { + onCommit(context): void { + if (shouldRecord && !shouldRecord(context)) { + return + } + + operationTypes.push( + ...context.commit.operations.map((operation) => operation.type) + ) + }, + } + }, + }) + ) + + return operationTypes +} + +export const recordOperationTypes = ( + peer: Peer, + options: OperationTypeRecorderOptions +): Operation['type'][] => recordEditorOperationTypes(peer.editor, options) + +export const disconnectYjsPeer = (peer: Peer): void => { + runYjsUpdate(peer, (yjs) => yjs.disconnect()) +} + +export const connectYjsPeer = (peer: Peer): void => { + runYjsUpdate(peer, (yjs) => yjs.connect()) +} + +export const undoYjsPeer = (peer: Peer): void => { + runYjsUpdate(peer, (yjs) => yjs.undo()) +} + +export const redoYjsPeer = (peer: Peer): void => { + runYjsUpdate(peer, (yjs) => yjs.redo()) +} + +export const clearYjsTrace = (peer: Peer): void => { + runYjsUpdate(peer, (yjs) => yjs.clearTrace()) +} + +export const reconcileYjsPeer = (peer: Peer): void => { + runYjsUpdate(peer, (yjs) => yjs.reconcile()) +} + +export const disconnectAndClearYjsTrace = (peer: Peer): void => { + disconnectYjsPeer(peer) + clearYjsTrace(peer) +} + +export const getHistoryUndoCount = (editor: TestEditor): number => + editor.read((state) => state.history.undos().length) + +export const undoEditorHistory = (editor: TestEditor): void => { + editor.update((tx) => { + tx.history.undo() + }) +} + +export const setEditorDomApi = ( + editor: TestEditor, + dom: TestEditorDomApi +): void => { + editor.api = { + ...editor.api, + dom: { + ...editor.api?.dom, + ...dom, + }, + } +} + +export const syncConnectedPeers = (peers: readonly Peer[]): void => { for (const source of peers) { - if (!getYjsState(source).connected()) { + if (!isYjsPeerConnected(source)) { continue } const update = Y.encodeStateAsUpdate(source.doc) for (const target of peers) { - if (source === target || !getYjsState(target).connected()) { + if (source === target || !isYjsPeerConnected(target)) { continue } @@ -235,17 +341,35 @@ export const syncConnectedPeers = (peers: Peer[]) => { } } -export const assertNoRootSnapshot = (peer: Peer) => { - assert.equal( - getYjsState(peer) - .trace() - .some((entry: { mode: string }) => entry.mode === 'root-snapshot'), - false - ) +export const connectYjsPeerAndSync = ( + peer: Peer, + peers: readonly Peer[] +): void => { + connectYjsPeer(peer) + syncConnectedPeers(peers) +} + +export const undoYjsPeerAndSync = ( + peer: Peer, + peers: readonly Peer[] +): void => { + undoYjsPeer(peer) + syncConnectedPeers(peers) +} + +export const redoYjsPeerAndSync = ( + peer: Peer, + peers: readonly Peer[] +): void => { + redoYjsPeer(peer) + syncConnectedPeers(peers) } -export const assertPeerTexts = (peers: Peer[], expected: string[]) => { +export const assertPeerTexts = ( + peers: readonly Peer[], + expected: readonly string[] +): void => { for (const [index, peer] of peers.entries()) { - assert.deepEqual(getParagraphTexts(peer), expected, `peer ${index}`) + assert.deepEqual(getPeerTopLevelTexts(peer), expected, `peer ${index}`) } } diff --git a/packages/slate-yjs/test/support/provider.ts b/packages/slate-yjs/test/support/provider.ts new file mode 100644 index 0000000000..cda1cee9f9 --- /dev/null +++ b/packages/slate-yjs/test/support/provider.ts @@ -0,0 +1,187 @@ +import * as Y from 'yjs' + +import type { + YjsAwarenessChange, + YjsAwarenessLike, + YjsAwarenessState, + YjsProviderEvent, + YjsProviderEventHandler, + YjsProviderLike, + YjsProviderStatus, + YjsProviderStatusPayload, + YjsProviderSyncedPayload, +} from '../../src/core/types' + +type YjsProviderPayload = YjsProviderStatusPayload | YjsProviderSyncedPayload +type YjsProviderPayloadHandler = (payload: YjsProviderPayload) => void +type ProviderCall = 'connect' | 'destroy' | 'disconnect' + +const toProviderPayloadHandler = ( + handler: YjsProviderEventHandler +): YjsProviderPayloadHandler => handler as YjsProviderPayloadHandler + +const emitProviderPayload = ( + listeners: ReadonlySet, + payload: YjsProviderPayload +): void => { + for (const listener of listeners) { + listener(payload) + } +} + +export class FakeAwareness implements YjsAwarenessLike { + readonly clientID: number + readonly doc: { readonly clientID: number } + + private readonly listeners = new Set<(event: YjsAwarenessChange) => void>() + private localState: YjsAwarenessState | null = null + private readonly states = new Map() + + constructor(clientID: number) { + this.clientID = clientID + this.doc = { clientID } + } + + getLocalState(): YjsAwarenessState | null { + return this.localState + } + + getStates(): ReadonlyMap { + return this.states + } + + off(_event: 'change', handler: (event: YjsAwarenessChange) => void): void { + this.listeners.delete(handler) + } + + on(_event: 'change', handler: (event: YjsAwarenessChange) => void): void { + this.listeners.add(handler) + } + + removeRemoteState(clientId: number): void { + this.states.delete(clientId) + this.emit({ added: [], removed: [clientId], updated: [] }) + } + + setLocalStateField(field: string, value: unknown): void { + this.localState = { + ...(this.localState ?? {}), + [field]: value, + } + this.states.set(this.clientID, this.localState) + this.emit({ added: [], removed: [], updated: [this.clientID] }) + } + + setRemoteState(clientId: number, state: YjsAwarenessState): void { + const hasState = this.states.has(clientId) + const added = hasState ? [] : [clientId] + const updated = hasState ? [clientId] : [] + + this.states.set(clientId, state) + this.emit({ added, removed: [], updated }) + } + + private emit(event: YjsAwarenessChange): void { + for (const listener of this.listeners) { + listener(event) + } + } +} + +export class FakeProvider implements YjsProviderLike { + readonly awareness: FakeAwareness + readonly calls: ProviderCall[] = [] + readonly doc?: Y.Doc + + status: YjsProviderStatus + synced?: boolean + + private readonly statusListeners = new Set() + private readonly syncedListeners = new Set() + private readonly syncListeners = new Set() + + constructor({ + awarenessClientId = 12, + doc = new Y.Doc(), + exposeDoc = true, + exposeSynced = true, + status = 'disconnected', + synced = false, + }: { + readonly awarenessClientId?: number + readonly doc?: Y.Doc + readonly exposeDoc?: boolean + readonly exposeSynced?: boolean + readonly status?: YjsProviderStatus + readonly synced?: boolean + } = {}) { + this.awareness = new FakeAwareness(awarenessClientId) + this.status = status + + if (exposeDoc) { + this.doc = doc + } + if (exposeSynced) { + this.synced = synced + } + } + + connect(): void { + this.calls.push('connect') + this.emitStatus('connected') + } + + destroy(): void { + this.calls.push('destroy') + } + + disconnect(): void { + this.calls.push('disconnect') + this.emitStatus('disconnected') + } + + emitStatus(status: YjsProviderStatusPayload): void { + this.status = typeof status === 'string' ? status : status.status + + emitProviderPayload(this.statusListeners, status) + } + + emitSynced(synced: boolean): void { + this.synced = synced + + emitProviderPayload(this.syncedListeners, synced) + } + + emitSyncedState(synced: boolean): void { + this.synced = synced + + emitProviderPayload(this.syncedListeners, { state: synced }) + } + + emitSync(synced: boolean): void { + this.synced = synced + + emitProviderPayload(this.syncListeners, synced) + } + + off(event: YjsProviderEvent, handler: YjsProviderEventHandler): void { + this.listenersFor(event).delete(toProviderPayloadHandler(handler)) + } + + on(event: YjsProviderEvent, handler: YjsProviderEventHandler): void { + this.listenersFor(event).add(toProviderPayloadHandler(handler)) + } + + private listenersFor( + event: YjsProviderEvent + ): Set { + if (event === 'status') { + return this.statusListeners + } + if (event === 'sync') { + return this.syncListeners + } + + return this.syncedListeners + } +} diff --git a/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts b/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts index 96598566a0..080a990f4c 100644 --- a/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts +++ b/packages/slate-yjs/test/unwrap-nodes-contract.spec.ts @@ -1,18 +1,24 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type Descendant, defineEditorExtension } from 'slate' -import { Editor } from 'slate/internal' +import type { Descendant, Operation } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + clearYjsTrace, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectYjsPeer, + getPeerTopLevelTexts, + getPeerTopLevelTypes, getYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + recordOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -21,17 +27,11 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [paragraph('alpha')] +const initialValue = (): Descendant[] => [paragraph('alpha')] -const createPeer = ( - clientId: keyof typeof clientIds, - seedUpdate?: Uint8Array -) => +const createPeer = (clientId: ClientId, seedUpdate?: Uint8Array): Peer => createYjsPeer({ children: initialValue(), clientId, @@ -39,32 +39,27 @@ const createPeer = ( seedUpdate, }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const topLevelTypes = (peer: ReturnType) => - Editor.getSnapshot(peer.editor).children.map((node) => - 'type' in node ? node.type : 'text' - ) - -const wrapFirstBlock = (peer: ReturnType) => { +const wrapFirstBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.wrap({ children: [], type: 'quote' }, { at: [0] }) }) } -const unwrapFirstBlock = (peer: ReturnType) => { +const unwrapFirstBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.unwrap({ at: [0] }) }) } -const appendRemoteText = (peer: ReturnType) => { - const [type] = topLevelTypes(peer) +const appendRemoteText = (peer: Peer): void => { + const [type] = getPeerTopLevelTypes(peer) const textPath = type === 'quote' ? [0, 0, 0] : [0, 0] peer.editor.update((tx) => { @@ -72,46 +67,38 @@ const appendRemoteText = (peer: ReturnType) => { }) } -const createWrappedPeer = (clientId: keyof typeof clientIds) => { +const createWrappedPeer = (clientId: ClientId): Peer => { const peer = createPeer(clientId) wrapFirstBlock(peer) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) return peer } -const createWrappedPeers = (ids: Array) => { +const createWrappedPeers = (ids: readonly ClientId[]): Peer[] => { const peers = createPeers(ids) + const [firstPeer] = peers + + if (firstPeer === undefined) { + throw new Error('Expected at least one wrapped peer.') + } - wrapFirstBlock(peers[0]!) + wrapFirstBlock(firstPeer) syncConnectedPeers(peers) for (const peer of peers) { - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) } return peers } -const collectUnwrapOperations = () => { +const collectUnwrapOperations = (): Operation['type'][] => { const peer = createWrappedPeer('b') - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'unwrap-operation-recorder', - setup() { - return { - onCommit({ commit }) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'unwrap-operation-recorder', + }) unwrapFirstBlock(peer) return operations @@ -126,13 +113,13 @@ describe('@slate/yjs unwrapNodes collaboration contract', () => { const peer = createWrappedPeer('b') const original = getYjsNodeAt(peer, [1]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) + disconnectYjsPeer(peer) unwrapFirstBlock(peer) - assert.deepEqual(getParagraphTexts(peer), ['alpha']) - assert.deepEqual(topLevelTypes(peer), ['paragraph']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha']) + assert.deepEqual(getPeerTopLevelTypes(peer), ['paragraph']) assert.equal(getYjsNodeAt(peer, [0]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { fallback: 'virtual-unwrap-ref', mode: 'traceable-fallback', @@ -144,68 +131,60 @@ describe('@slate/yjs unwrapNodes collaboration contract', () => { operationType: 'remove_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline unwrap reconnects', () => { const peers = createWrappedPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) unwrapFirstBlock(b) appendRemoteText(a) syncConnectedPeers(peers) - assert.deepEqual(getParagraphTexts(a), ['alpha!']) - assert.deepEqual(topLevelTypes(a), ['quote']) - assert.deepEqual(getParagraphTexts(b), ['alpha']) - assert.deepEqual(topLevelTypes(b), ['paragraph']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!']) + assert.deepEqual(getPeerTopLevelTypes(a), ['quote']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha']) + assert.deepEqual(getPeerTopLevelTypes(b), ['paragraph']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(a), ['paragraph']) - assert.deepEqual(topLevelTypes(b), ['paragraph']) - assertNoRootSnapshot(b) + assert.deepEqual(getPeerTopLevelTypes(a), ['paragraph']) + assert.deepEqual(getPeerTopLevelTypes(b), ['paragraph']) }) it('recovers unwrap convergence through real Yjs updates after reconnect', () => { const peers = createWrappedPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) unwrapFirstBlock(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha']) - assert.deepEqual(topLevelTypes(b), ['paragraph']) + assert.deepEqual(getPeerTopLevelTypes(b), ['paragraph']) }) it('undoes and redoes only the local unwrap intent after reconnect', () => { const peers = createWrappedPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) unwrapFirstBlock(b) appendRemoteText(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(b), ['paragraph']) + assert.deepEqual(getPeerTopLevelTypes(b), ['paragraph']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(b), ['quote']) + assert.deepEqual(getPeerTopLevelTypes(b), ['quote']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(b), ['paragraph']) - assertNoRootSnapshot(b) + assert.deepEqual(getPeerTopLevelTypes(b), ['paragraph']) }) }) diff --git a/packages/slate-yjs/test/wrap-nodes-contract.spec.ts b/packages/slate-yjs/test/wrap-nodes-contract.spec.ts index b0a6adbc20..1ba0d38bd5 100644 --- a/packages/slate-yjs/test/wrap-nodes-contract.spec.ts +++ b/packages/slate-yjs/test/wrap-nodes-contract.spec.ts @@ -1,18 +1,27 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type Descendant, defineEditorExtension } from 'slate' -import { Editor } from 'slate/internal' -import { readSlateValueFromYjs } from '../src/core/document' +import type { Descendant, Operation } from 'slate' import { - assertNoRootSnapshot, assertPeerTexts, + clearYjsTrace, + connectYjsPeerAndSync, createSeededYjsPeers, createYjsPeer, - getParagraphTexts, + disconnectAndClearYjsTrace, + disconnectYjsPeer, + getPeerTopLevelTexts, + getPeerTopLevelTypes, getYjsNodeAt, - getYjsState, - runYjsUpdate, + getYjsTrace, + type Peer, + paragraph, + readPeerChildren, + readPeerSelection, + readPeerSlateValue, + recordOperationTypes, + redoYjsPeerAndSync, syncConnectedPeers, + undoYjsPeerAndSync, } from './support/collaboration' const clientIds = { @@ -21,17 +30,11 @@ const clientIds = { c: 3, } as const -const paragraph = (text: string): Descendant => ({ - type: 'paragraph', - children: [{ text }], -}) +type ClientId = keyof typeof clientIds -const initialValue = () => [paragraph('alpha')] +const initialValue = (): Descendant[] => [paragraph('alpha')] -const createPeer = ( - clientId: keyof typeof clientIds, - seedUpdate?: Uint8Array -) => +const createPeer = (clientId: ClientId, seedUpdate?: Uint8Array): Peer => createYjsPeer({ children: initialValue(), clientId, @@ -39,48 +42,30 @@ const createPeer = ( seedUpdate, }) -const createPeers = (ids: Array) => +const createPeers = (ids: readonly ClientId[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds: ids, numericClientIds: clientIds, }) -const topLevelTypes = (peer: ReturnType) => - Editor.getSnapshot(peer.editor).children.map((node) => - 'type' in node ? node.type : 'text' - ) - -const wrapFirstBlock = (peer: ReturnType) => { +const wrapFirstBlock = (peer: Peer): void => { peer.editor.update((tx) => { tx.nodes.wrap({ children: [], type: 'quote' }, { at: [0] }) }) } -const appendRemoteText = (peer: ReturnType) => { +const appendRemoteText = (peer: Peer): void => { peer.editor.update((tx) => { tx.text.insert('!', { at: { path: [0, 0], offset: 'alpha'.length } }) }) } -const collectWrapOperations = () => { +const collectWrapOperations = (): Operation['type'][] => { const peer = createPeer('b') - const operations: string[] = [] - - peer.editor.extend( - defineEditorExtension({ - name: 'wrap-operation-recorder', - setup() { - return { - onCommit({ commit }) { - operations.push( - ...commit.operations.map((operation) => operation.type) - ) - }, - } - }, - }) - ) + const operations = recordOperationTypes(peer, { + name: 'wrap-operation-recorder', + }) wrapFirstBlock(peer) return operations @@ -95,14 +80,13 @@ describe('@slate/yjs wrapNodes collaboration contract', () => { const peer = createPeer('b') const original = getYjsNodeAt(peer, [0]) - runYjsUpdate(peer, (yjs) => yjs.disconnect()) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + disconnectAndClearYjsTrace(peer) wrapFirstBlock(peer) - assert.deepEqual(getParagraphTexts(peer), ['alpha']) - assert.deepEqual(topLevelTypes(peer), ['quote']) + assert.deepEqual(getPeerTopLevelTexts(peer), ['alpha']) + assert.deepEqual(getPeerTopLevelTypes(peer), ['quote']) assert.equal(getYjsNodeAt(peer, [1]), original) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'insert_node' }, { fallback: 'virtual-move-ref', @@ -110,37 +94,34 @@ describe('@slate/yjs wrapNodes collaboration contract', () => { operationType: 'move_node', }, ]) - assertNoRootSnapshot(peer) }) it('preserves concurrent remote text when an offline wrap reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) wrapFirstBlock(b) appendRemoteText(a) syncConnectedPeers(peers) - assert.deepEqual(getParagraphTexts(a), ['alpha!']) - assert.deepEqual(topLevelTypes(a), ['paragraph']) - assert.deepEqual(getParagraphTexts(b), ['alpha']) - assert.deepEqual(topLevelTypes(b), ['quote']) + assert.deepEqual(getPeerTopLevelTexts(a), ['alpha!']) + assert.deepEqual(getPeerTopLevelTypes(a), ['paragraph']) + assert.deepEqual(getPeerTopLevelTexts(b), ['alpha']) + assert.deepEqual(getPeerTopLevelTypes(b), ['quote']) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(a), ['quote']) - assert.deepEqual(topLevelTypes(b), ['quote']) - assertNoRootSnapshot(b) + assert.deepEqual(getPeerTopLevelTypes(a), ['quote']) + assert.deepEqual(getPeerTopLevelTypes(b), ['quote']) }) it('splits text inside a virtual wrapped block without a root snapshot fallback', () => { const peer = createPeer('b') wrapFirstBlock(peer) - runYjsUpdate(peer, (yjs) => yjs.clearTrace()) + clearYjsTrace(peer) peer.editor.update((tx) => { tx.selection.set({ @@ -150,23 +131,22 @@ describe('@slate/yjs wrapNodes collaboration contract', () => { tx.break.insert() }) - assert.deepEqual(Editor.getSnapshot(peer.editor).children, [ + assert.deepEqual(readPeerChildren(peer), [ { children: [paragraph('al'), paragraph('pha')], type: 'quote', }, ]) - assert.deepEqual(readSlateValueFromYjs(getYjsState(peer).root()), [ + assert.deepEqual(readPeerSlateValue(peer), [ { children: [paragraph('al'), paragraph('pha')], type: 'quote', }, ]) - assert.deepEqual(getYjsState(peer).trace(), [ + assert.deepEqual(getYjsTrace(peer), [ { mode: 'operation', operationType: 'split_node' }, { mode: 'operation', operationType: 'split_node' }, ]) - assertNoRootSnapshot(peer) }) it('drops a preserved selection that no longer points to text after remote wrap import', () => { @@ -180,56 +160,49 @@ describe('@slate/yjs wrapNodes collaboration contract', () => { }) }) - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) wrapFirstBlock(b) appendRemoteText(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(a), ['quote']) - assert.equal(Editor.getSnapshot(a.editor).selection, null) - assertNoRootSnapshot(b) + assert.deepEqual(getPeerTopLevelTypes(a), ['quote']) + assert.equal(readPeerSelection(a), null) }) it('recovers wrap convergence through real Yjs updates after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) wrapFirstBlock(b) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha']) - assert.deepEqual(topLevelTypes(b), ['quote']) + assert.deepEqual(getPeerTopLevelTypes(b), ['quote']) }) it('undoes and redoes only the local wrap intent after reconnect', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers - runYjsUpdate(b, (yjs) => yjs.disconnect()) + disconnectYjsPeer(b) wrapFirstBlock(b) appendRemoteText(a) syncConnectedPeers(peers) - runYjsUpdate(b, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + connectYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(b), ['quote']) + assert.deepEqual(getPeerTopLevelTypes(b), ['quote']) - runYjsUpdate(b, (yjs) => yjs.undo()) - syncConnectedPeers(peers) + undoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(b), ['paragraph']) + assert.deepEqual(getPeerTopLevelTypes(b), ['paragraph']) - runYjsUpdate(b, (yjs) => yjs.redo()) - syncConnectedPeers(peers) + redoYjsPeerAndSync(b, peers) assertPeerTexts(peers, ['alpha!']) - assert.deepEqual(topLevelTypes(b), ['quote']) - assertNoRootSnapshot(b) + assert.deepEqual(getPeerTopLevelTypes(b), ['quote']) }) }) From 2bd99fefad656d5268c6f611d6d9caa90ec37af5 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sat, 13 Jun 2026 12:03:41 +0800 Subject: [PATCH 09/11] Improve Yjs collaboration stability --- ...26-06-13-yjs-awareness-reconnect-cursor.md | 112 ++ package.json | 1 + packages/slate-yjs/src/core/attributes.ts | 41 +- .../slate-yjs/src/core/awareness-adapter.ts | 127 +- packages/slate-yjs/src/core/controller.ts | 102 +- packages/slate-yjs/src/core/document.ts | 1197 +++++++++++++++-- packages/slate-yjs/src/core/editor-adapter.ts | 61 +- packages/slate-yjs/src/core/history.ts | 57 +- packages/slate-yjs/src/core/json-equality.ts | 73 + packages/slate-yjs/src/core/operations.ts | 214 ++- packages/slate-yjs/src/core/path.ts | 85 +- .../src/core/provider-lifecycle-adapter.ts | 71 +- packages/slate-yjs/src/core/replacement.ts | 264 +++- packages/slate-yjs/src/core/selection.ts | 7 +- .../src/core/split-history-adapter.ts | 139 +- packages/slate-yjs/src/core/split-history.ts | 265 +++- .../src/core/undo-manager-adapter.ts | 29 +- packages/slate-yjs/src/react/index.ts | 310 ++++- .../test/attributes-contract.spec.ts | 87 +- .../slate-yjs/test/awareness-contract.spec.ts | 36 + .../slate-yjs/test/history-contract.spec.ts | 71 + .../slate-yjs/test/move-node-contract.spec.ts | 30 + .../operation-exhaustiveness-contract.spec.ts | 30 +- .../slate-yjs/test/provider-contract.spec.ts | 116 ++ .../slate-yjs/test/react-contract.spec.tsx | 89 ++ .../test/remove-node-contract.spec.ts | 134 +- .../test/replace-fragment-contract.spec.ts | 94 ++ .../test/simple-operations-contract.spec.ts | 120 +- .../test/split-merge-contract.spec.ts | 45 + .../test/split-node-contract.spec.ts | 79 ++ scripts/benchmarks/README.md | 3 + .../core/current/yjs-collaboration.mjs | 196 ++- scripts/proof/yjs-collaboration-soak.mjs | 80 +- 33 files changed, 3762 insertions(+), 603 deletions(-) create mode 100644 docs/plans/2026-06-13-yjs-awareness-reconnect-cursor.md create mode 100644 packages/slate-yjs/src/core/json-equality.ts create mode 100644 packages/slate-yjs/test/history-contract.spec.ts diff --git a/docs/plans/2026-06-13-yjs-awareness-reconnect-cursor.md b/docs/plans/2026-06-13-yjs-awareness-reconnect-cursor.md new file mode 100644 index 0000000000..f4c52ffa43 --- /dev/null +++ b/docs/plans/2026-06-13-yjs-awareness-reconnect-cursor.md @@ -0,0 +1,112 @@ +# Yjs Awareness Reconnect Cursor + +Objective: +Fix the Hocuspocus awareness reconnect remote cursor regression; done when a failing-first regression and focused Hocuspocus awareness soak pass. + +Flow mode: +one-shot execution + +Goal plan: +docs/plans/2026-06-13-yjs-awareness-reconnect-cursor.md + +Primary template: +manual task plan, because the autogoal helper requires an AGENTS.md repo root and slate-v2 has none. + +Applied packs: +- browser +- package-api + +Requirements: +- Reproduce the reported bug on `http://localhost:3100/examples/yjs-hocuspocus?room=awareness-repro-1`. +- Use TDD: add a regression that fails before implementation. +- Preserve the scope: this is an awareness / remote cursor reconnect bug, not a content sync bug. +- Expected behavior: Peer A Select makes other peers show Peer A remote cursor text containing `101:0`. +- Expected behavior: Peer A Offline removes Peer A remote cursor from other peers. +- Expected behavior: Peer A Online followed by Select makes other peers show valid Peer A remote cursor text again. +- The old bad text shape is `202:null | 303:null | 404:null | 303:null | 404:null | 202:null | 404:null | 202:null | 303:null`. +- Use the provided soak command shape with `SOAK_FAIL_ON_ISSUES=1`. +- Do not run git state checks unless explicitly requested. +- Do not commit, push, or open PR. + +Completion threshold: +- RED: a focused awareness reconnect regression fails before the fix with missing/empty remote cursor selection or label after reconnect. +- GREEN: the same regression passes. +- Browser/provider proof: the focused Hocuspocus awareness soak passes with `SOAK_FAIL_ON_ISSUES=1`. +- Type/lint proof covers modified package/example files. +- Completion checker is attempted; it may be N/A because slate-v2 has no AGENTS.md root for the shared autogoal script. + +Verification surface: +- `packages/slate-yjs/test/**` for package-level behavior. +- `site/examples/ts/yjs-hocuspocus.tsx` only if the example owns the bug. +- `scripts/proof/yjs-collaboration-soak.mjs` and `test-results/yjs-collaboration-soak/**` for browser/provider proof. + +Constraints: +- Fix the real ownership boundary, not a runner-only mask. +- Keep the raw webhook/provider room behavior untouched unless evidence proves the example integration is at fault. +- Do not broaden into unrelated structural Yjs bugs. +- Do not claim done from package tests alone; this bug was found on real Hocuspocus. + +Boundaries: +- Allowed: `packages/slate-yjs/**`, `site/examples/ts/yjs-hocuspocus.tsx`, `scripts/proof/**`, `docs/plans/**`. +- Avoid: release, publish, changeset, PR, unrelated Plate files. + +Blocked condition: +- Stop only if local Hocuspocus/browser infrastructure cannot run after three distinct attempts, or if the fix requires a public API decision that cannot be inferred from current `@slate/yjs` contracts. + +Start Gates: +| Gate | Applies | Evidence | +| --- | --- | --- | +| User requirements extracted | yes | Listed above from the latest user prompt and prior reproduction. | +| Existing goal checked | yes | `get_goal` returned no active goal before `create_goal`. | +| Active goal created | yes | Goal objective names this plan and focused pass threshold. | +| Reproduction confirmed | yes | `SOAK_RUN_ID=awareness-repro` failed with `awareness-missing-after-reconnect`. | +| TDD skill read | yes | Read `/Users/felixfeng/Desktop/repos/plate-copy/.agents/skills/tdd/SKILL.md`. | + +Work Checklist: +- [x] Reproduce current bug. +- [x] RED: add focused awareness reconnect regression. +- [x] Confirm regression fails for the reported reason. +- [x] Fix awareness reconnect ownership boundary. +- [x] GREEN: run focused regression. +- [x] Run focused Hocuspocus awareness soak with fail-on-issues. +- [x] Run relevant type/lint checks. +- [x] Review changed code for scope/correctness. +- [x] Run completion checker. + +Completion Gates: +| Gate | Applies | Required action | Evidence | +| --- | --- | --- | --- | +| TDD red proof | yes | Record failing test command/output before fix. | `bun test packages/slate-yjs/test/provider-contract.spec.ts` failed before fix: after `yjs.connect(); yjs.sendSelection(range, { name: 'Ada' })`, Peer B `remoteCursors()` was `[]` instead of client `101` with data and selection. | +| Focused regression | yes | Same test passes after fix. | `bun test packages/slate-yjs/test/provider-contract.spec.ts` passed: 31 pass, 0 fail. | +| Hocuspocus soak | yes | Provided awareness command shape passes with `SOAK_FAIL_ON_ISSUES=1`. | `SOAK_RUN_ID=awareness-repro-after-fix ... bun scripts/proof/yjs-collaboration-soak.mjs` exited 0; summary reports actions 79, awareness 1, issues 0. | +| Type/lint | yes | Run owning package/example check. | `bun test ./packages/slate-yjs/test` passed: 213 pass, 0 fail; `bun --filter @slate/yjs typecheck` passed; `bunx biome check --write ...` passed. | +| Review | yes | Manual review for overreach and missing behavior coverage. | Autoreview helper was blocked by local Codex config `service_tier=priority`; manual review accepted the narrow lifecycle fix and rejected no findings. | +| Goal plan complete | N/A | Run autogoal checker after evidence is recorded. | Shared checker failed before reading this plan because slate-v2 has no AGENTS.md root; recorded as tool limitation, not product blocker. | + +Phase / pass table: +| Phase | Status | Evidence | Next | +| --- | --- | --- | --- | +| Reproduce | complete | `test-results/yjs-collaboration-soak/awareness-repro/summary.md` shows one issue. | Write RED test. | +| TDD fix | complete | RED provider contract failed, then passed after clearing local awareness selection on provider disconnect. | Done. | +| Browser proof | complete | `test-results/yjs-collaboration-soak/awareness-repro-after-fix/summary.md` reports issues 0. | Done. | +| Completion audit | complete | Package tests, typecheck, Biome, manual review, checker limitation recorded. | Done. | + +Verification evidence: +- 2026-06-13: `SOAK_BASE_URL=http://localhost:3100 SOAK_URL=http://localhost:3100/examples/yjs-hocuspocus SOAK_START_SERVER=0 SOAK_MS=90000 SOAK_ACTION_DELAY_MS=1000 SOAK_HEADLESS=1 SOAK_FAIL_ON_ISSUES=1 SOAK_RUN_ID=awareness-repro bun scripts/proof/yjs-collaboration-soak.mjs` exited 1; summary reports `awareness-missing-after-reconnect` and `reselectedCursorTexts` all `*:null`. +- 2026-06-13 RED: `bun test packages/slate-yjs/test/provider-contract.spec.ts` failed with Peer B `remoteCursors()` equal to `[]` after Peer A reconnect and same selection resend. +- 2026-06-13 fix: provider lifecycle passes the connected boolean to the controller; the controller clears local awareness selection when the provider disconnects, preserving cursor data and forcing the next same selection to rebroadcast. +- 2026-06-13 GREEN focused: `bun test packages/slate-yjs/test/provider-contract.spec.ts` passed: 31 pass, 0 fail. +- 2026-06-13 package proof: `bun test packages/slate-yjs/test/awareness-contract.spec.ts packages/slate-yjs/test/provider-contract.spec.ts` passed: 42 pass, 0 fail. +- 2026-06-13 browser/provider proof: `SOAK_BASE_URL=http://localhost:3100 SOAK_URL=http://localhost:3100/examples/yjs-hocuspocus SOAK_START_SERVER=0 SOAK_MS=90000 SOAK_ACTION_DELAY_MS=1000 SOAK_HEADLESS=1 SOAK_FAIL_ON_ISSUES=1 SOAK_RUN_ID=awareness-repro-after-fix bun scripts/proof/yjs-collaboration-soak.mjs` exited 0; summary reports issues 0. +- 2026-06-13 package suite: `bun test ./packages/slate-yjs/test` passed: 213 pass, 0 fail. +- 2026-06-13 formatting/typecheck: `bunx biome check --write packages/slate-yjs/src/core/controller.ts packages/slate-yjs/src/core/provider-lifecycle-adapter.ts packages/slate-yjs/test/provider-contract.spec.ts` passed; `bun --filter @slate/yjs typecheck` passed. +- 2026-06-13 review: `/Users/felixfeng/Desktop/repos/plate-copy/.agents/skills/autoreview/scripts/autoreview --mode local ...` failed before review because local Codex config has unknown `service_tier=priority`; manual review found no accepted findings. +- 2026-06-13 checker: `node /Users/felixfeng/Desktop/repos/plate-copy/.agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-06-13-yjs-awareness-reconnect-cursor.md` failed before plan read because slate-v2 has no AGENTS.md repo root. + +Reboot status: +| Where am I? | Where am I going? | What is the goal? | What learned? | What done? | +| --- | --- | --- | --- | --- | +| Completion audit | Final handoff | Fix the Hocuspocus awareness reconnect cursor regression with TDD | Same awareness payloads were skipped after reconnect because local selection stayed equal; clearing presence selection on disconnect makes the next same select rebroadcast | RED/GREEN test, real Hocuspocus soak, package suite, typecheck, and review pass are complete | + +Open risks: +- None known for this bug after focused package and Hocuspocus proof. The shared autogoal checker cannot validate this plan because slate-v2 has no AGENTS.md root. diff --git a/package.json b/package.json index 06a933c1ee..959c53f2ac 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "bench:core:history-retained-memory:local": "bun ./scripts/benchmarks/core/current/history-retained-memory.mjs", "bench:core:clipboard-large-payload:local": "bun ./scripts/benchmarks/core/current/clipboard-large-payload.mjs", "bench:core:collab-readiness:local": "bun ./scripts/benchmarks/core/current/collab-readiness.mjs", + "bench:core:yjs-collaboration:local": "bun ./scripts/benchmarks/core/current/yjs-collaboration.mjs", "bench:core:normalization:compare:local": "bun ./scripts/benchmarks/core/compare/normalization.mjs", "bench:core:observation:compare:local": "bun ./scripts/benchmarks/core/compare/observation.mjs", "bench:core:huge-document:compare:local": "bun ./scripts/benchmarks/core/compare/huge-document.mjs", diff --git a/packages/slate-yjs/src/core/attributes.ts b/packages/slate-yjs/src/core/attributes.ts index 7460e16718..d9bfe111c6 100644 --- a/packages/slate-yjs/src/core/attributes.ts +++ b/packages/slate-yjs/src/core/attributes.ts @@ -12,8 +12,17 @@ type YjsAttributeWriter = { export const getYjsAttributes = (node: YjsNode): YjsAttributeRecord => toYjsAttributeRecord(node.getAttributes()) -export const hasYjsAttributes = (node: YjsNode): boolean => - Object.keys(getYjsAttributes(node)).length > 0 +export const hasYjsAttributes = (node: YjsNode): boolean => { + const attributes = node.getAttributes() + + for (const key in attributes) { + if (Object.hasOwn(attributes, key)) { + return true + } + } + + return false +} export const setYjsAttribute = ( node: YjsNode, @@ -28,7 +37,17 @@ export const setYjsAttribute = ( export const toYjsAttributeRecord = ( attributes: Readonly> -): YjsAttributeRecord => ({ ...attributes }) +): YjsAttributeRecord => { + const record: YjsAttributeRecord = {} + + for (const key in attributes) { + if (Object.hasOwn(attributes, key)) { + record[key] = attributes[key] + } + } + + return record +} export const formatYjsTextAttributes = ( text: Y.XmlText, @@ -43,8 +62,12 @@ export const setYjsAttributes = ( node: YjsNode, attributes: YjsAttributeRecord ): void => { - for (const [key, value] of Object.entries(attributes)) { - setYjsAttribute(node, key, value) + for (const key in attributes) { + if (!Object.hasOwn(attributes, key)) { + continue + } + + setYjsAttribute(node, key, attributes[key]) } } @@ -79,7 +102,11 @@ export const setSlateYjsAttributes = ( node: YjsNode, attributes: YjsAttributeRecord ): void => { - for (const [key, value] of Object.entries(attributes)) { - setSlateYjsAttribute(node, key, value) + for (const key in attributes) { + if (!Object.hasOwn(attributes, key)) { + continue + } + + setSlateYjsAttribute(node, key, attributes[key]) } } diff --git a/packages/slate-yjs/src/core/awareness-adapter.ts b/packages/slate-yjs/src/core/awareness-adapter.ts index c00da3564f..0aaaef3c15 100644 --- a/packages/slate-yjs/src/core/awareness-adapter.ts +++ b/packages/slate-yjs/src/core/awareness-adapter.ts @@ -7,6 +7,7 @@ import { yjsAwarenessSelectionsEqual, } from './awareness' import { getYjsLength, getYjsNodeIf } from './document' +import { areJsonLikeValuesEqual } from './json-equality' import { isRecord } from './record' import type { YjsAwarenessLike, @@ -46,8 +47,30 @@ export type YjsAwarenessAdapter = { } const getSortedAwarenessClientIds = ( - awareness: YjsAwarenessLike -): readonly number[] => [...awareness.getStates().keys()].sort((a, b) => a - b) + awareness: YjsAwarenessLike, + localClientId: number +): readonly number[] => { + const states = awareness.getStates() + const clientIds = new Array(states.size) + let writeIndex = 0 + + for (const clientId of states.keys()) { + if (clientId === localClientId) { + continue + } + + clientIds[writeIndex] = clientId + writeIndex++ + } + + clientIds.length = writeIndex + + if (clientIds.length > 1) { + clientIds.sort((a, b) => a - b) + } + + return clientIds +} const readRemoteCursorRecordData = < TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, @@ -90,7 +113,8 @@ export const createYjsAwarenessAdapter = ({ } const sanitizeYjsSelection = (range: Range): Range | null => - ([range.anchor, range.focus] as const).every(isValidYjsSelectionPoint) + isValidYjsSelectionPoint(range.anchor) && + isValidYjsSelectionPoint(range.focus) ? range : null @@ -110,16 +134,13 @@ export const createYjsAwarenessAdapter = ({ } } - const remoteCursor = < + const readRemoteCursor = < TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, >( - remoteClientId: number + remoteClientId: number, + localClientId: number ): YjsRemoteCursor | null => { - if ( - awareness === undefined || - !isConnected() || - remoteClientId === getLocalAwarenessClientId() - ) { + if (awareness === undefined || remoteClientId === localClientId) { return null } @@ -134,14 +155,38 @@ export const createYjsAwarenessAdapter = ({ awarenessDataField ) - return { + const cursor: { + data?: TCursorData + clientId: number + selection: Range | null + } = { clientId: remoteClientId, - ...(data === undefined ? {} : { data }), selection: readYjsAwarenessSelection( root, state[awarenessSelectionField] ), } + + if (data !== undefined) { + cursor.data = data + } + + return cursor + } + + const remoteCursor = < + TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, + >( + remoteClientId: number + ): YjsRemoteCursor | null => { + if (awareness === undefined || !isConnected()) { + return null + } + + return readRemoteCursor( + remoteClientId, + getLocalAwarenessClientId() + ) } const remoteCursors = < @@ -151,15 +196,63 @@ export const createYjsAwarenessAdapter = ({ return [] } - return getSortedAwarenessClientIds(awareness).flatMap((remoteClientId) => { - const cursor = remoteCursor(remoteClientId) + const localClientId = getLocalAwarenessClientId() + const remoteClientIds = getSortedAwarenessClientIds( + awareness, + localClientId + ) + const cursors = new Array>( + remoteClientIds.length + ) + let writeIndex = 0 + let index = 0 + + while (index < remoteClientIds.length) { + const remoteClientId = remoteClientIds[index] + + if (typeof remoteClientId !== 'number') { + throw new Error( + 'Cannot read remote cursors from a sparse client id array.' + ) + } + + const cursor = readRemoteCursor( + remoteClientId, + localClientId + ) + + if (cursor !== null) { + cursors[writeIndex] = cursor + writeIndex++ + } + index++ + } + + cursors.length = writeIndex + + return cursors + } + + const setLocalStateFieldIfChanged = (field: string, value: unknown): void => { + if (awareness === undefined) { + return + } + + const localState = awareness.getLocalState() + + if ( + localState !== null && + field in localState && + areJsonLikeValuesEqual(localState[field], value) + ) { + return + } - return cursor === null ? [] : [cursor] - }) + awareness.setLocalStateField(field, value) } const sendCursorData = (data: YjsRemoteCursorData | null): void => { - awareness?.setLocalStateField(awarenessDataField, data) + setLocalStateFieldIfChanged(awarenessDataField, data) } const sendSelection = ( diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts index 6be34c66ce..aa36dbc848 100644 --- a/packages/slate-yjs/src/core/controller.ts +++ b/packages/slate-yjs/src/core/controller.ts @@ -50,21 +50,63 @@ const notifySubscribers = (subscribers: ReadonlySet<() => void>): void => { } } -const shouldSendCommitSelection = ( +const copyTraceEntries = ( + traceEntries: readonly YjsTraceEntry[] +): YjsTraceEntry[] => { + const copy = new Array(traceEntries.length) + + let index = 0 + + while (index < traceEntries.length) { + const entry = traceEntries[index] + + if (entry === undefined) { + throw new Error('Cannot copy a sparse Yjs trace array.') + } + + copy[index] = entry + index++ + } + + return copy +} + +const collectYjsCommitOperations = ( commit: EditorCommit, autoSendSelection: boolean -): boolean => - autoSendSelection && - commit.operations.some((operation) => operation.type === 'set_selection') - -const getYjsCommitOperations = ( - operations: readonly Operation[] -): Operation[] => - operations.filter( - (operation) => - operation.type !== 'set_selection' && - !isNoopSlateOperationForYjs(operation) - ) +): { + readonly operations: Operation[] + readonly shouldSendSelection: boolean +} => { + const operations = new Array(commit.operations.length) + let operationCount = 0 + let shouldSendSelection = false + let index = 0 + + while (index < commit.operations.length) { + const operation = commit.operations[index] + + if (operation === undefined) { + throw new Error('Cannot collect Yjs operations from a sparse commit.') + } + + if (operation.type === 'set_selection') { + shouldSendSelection = autoSendSelection || shouldSendSelection + index++ + continue + } + + if (!isNoopSlateOperationForYjs(operation)) { + operations[operationCount] = operation + operationCount++ + } + index++ + } + + operations.length = operationCount + + return { operations, shouldSendSelection } +} export class YjsController { private readonly autoSendSelection: boolean @@ -120,7 +162,12 @@ export class YjsController { this.updateAwarenessRevision() } this.providerLifecycle = createYjsProviderLifecycleAdapter({ - onConnectedChange: () => this.updateAwarenessRevision(), + onConnectedChange: (connected) => { + if (!connected) { + this.awarenessAdapter.clearSelection() + } + this.updateAwarenessRevision() + }, onProviderSyncedChange: () => this.reconcileProviderOwnedDocAfterSync(), provider: this.provider, }) @@ -175,7 +222,7 @@ export class YjsController { destroy(): void { this.unbindExternalEvents() - if (this.provider !== undefined) { + if (this.awareness !== undefined) { this.awarenessAdapter.clearSelection() } if (this.destroyProviderOnUnmount) { @@ -200,11 +247,10 @@ export class YjsController { return } - const shouldSendSelection = shouldSendCommitSelection( + const { operations, shouldSendSelection } = collectYjsCommitOperations( commit, this.autoSendSelection ) - const operations = getYjsCommitOperations(commit.operations) if (operations.length === 0) { if (shouldSendSelection) { @@ -231,22 +277,34 @@ export class YjsController { } const splitHistory = this.splitHistory.createFromOperations(operations) - const rejectedLocalOperations: Operation[] = [] + const rejectedLocalOperations = new Array(operations.length) + let rejectedLocalOperationCount = 0 this.undoManager.stopCapturing() this.doc.transact(() => { - for (const operation of operations) { + let operationIndex = 0 + + while (operationIndex < operations.length) { + const operation = operations[operationIndex] + + if (operation === undefined) { + throw new Error('Cannot apply Yjs operations from a sparse array.') + } + const trace = this.applyOperation(operation) if (this.shouldImportAfterLocalFallback(trace)) { - rejectedLocalOperations.push(operation) + rejectedLocalOperations[rejectedLocalOperationCount] = operation + rejectedLocalOperationCount++ } + operationIndex++ } }, this.localOrigin) this.splitHistory.store(splitHistory) this.undoManager.stopCapturing() - if (rejectedLocalOperations.length > 0) { + if (rejectedLocalOperationCount > 0) { + rejectedLocalOperations.length = rejectedLocalOperationCount this.editorAdapter.replaceValue( readSlateValueFromYjs(this.root), snapshot.selection @@ -298,7 +356,7 @@ export class YjsController { subscribeAwareness: (listener) => this.subscribeAwareness(listener), subscribeProvider: (listener) => this.providerLifecycle.subscribe(listener), - trace: () => [...this.traceEntries], + trace: () => copyTraceEntries(this.traceEntries), } } diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts index 7e69041ad9..734a175582 100644 --- a/packages/slate-yjs/src/core/document.ts +++ b/packages/slate-yjs/src/core/document.ts @@ -11,6 +11,8 @@ import { type YjsAttributeRecord, type YjsNode, } from './attributes' +import { areJsonLikeValuesEqual } from './json-equality' +import { copyPath, lastPathIndex, parentPath } from './path' import { getYjsTextDeltaPartText, isNonEmptyYjsTextDeltaPart, @@ -25,39 +27,228 @@ const VIRTUAL_YJS_CHILD_RAW_INDEX = -1 const INTERNAL_YJS_ATTRIBUTES = [ HIDDEN_ATTRIBUTE, NODE_ID_ATTRIBUTE, + SLATE_TYPE_ATTRIBUTE, SPLIT_UNDO_TEXT_ATTRIBUTE, VIRTUAL_CHILD_ID_ATTRIBUTE, VIRTUAL_PLACEHOLDER_ATTRIBUTE, ] as const +const INTERNAL_YJS_ATTRIBUTE_SET = new Set(INTERNAL_YJS_ATTRIBUTES) let nextNodeId = 0 const nodeIdScope = Math.random().toString(36).slice(2) export const getYjsLength = (node: YjsNode): number => node.length -export const getYjsTextContent = (node: Y.XmlText): string => - node.toDelta().map(getYjsTextDeltaPartText).join('') +export const getYjsTextContent = (node: Y.XmlText): string => { + if (getYjsLength(node) === 0) { + return '' + } + + let text = '' + const delta = node.toDelta() + + if (delta.length === 1) { + const part = delta[0] + + return part === undefined ? '' : getYjsTextDeltaPartText(part) + } + + let index = 0 + + while (index < delta.length) { + const part = delta[index] + + text += getYjsTextDeltaPartText(part) + index++ + } + + return text +} + +export const getYjsTextContentFrom = ( + node: Y.XmlText, + offset: number +): string => { + if (offset <= 0) { + return getYjsTextContent(node) + } + if (offset >= getYjsLength(node)) { + return '' + } + + let text = '' + let skipped = 0 + const delta = node.toDelta() + + if (delta.length === 1) { + const part = delta[0] + const partText = part === undefined ? '' : getYjsTextDeltaPartText(part) + + return partText.slice(offset) + } + + let index = 0 + + while (index < delta.length) { + const part = delta[index] + const partText = getYjsTextDeltaPartText(part) + const nextSkipped = skipped + partText.length + + if (offset <= skipped) { + text += partText + } else if (offset < nextSkipped) { + text += partText.slice(offset - skipped) + } + + skipped = nextSkipped + index++ + } + + return text +} + +export const yjsTextContentEndsWith = ( + node: Y.XmlText, + suffix: string +): boolean => { + if (suffix.length === 0) { + return true + } + if (getYjsLength(node) < suffix.length) { + return false + } + + const delta = node.toDelta() + + if (delta.length === 1) { + const part = delta[0] + const partText = part === undefined ? '' : getYjsTextDeltaPartText(part) + + return partText.endsWith(suffix) + } + + let suffixIndex = suffix.length + let index = delta.length - 1 + + while (index >= 0 && suffixIndex > 0) { + const part = delta[index] + + if (part === undefined) { + index-- + continue + } + + const partText = getYjsTextDeltaPartText(part) + + if (partText.length === 0) { + index-- + continue + } + + let partIndex = partText.length - 1 + + while (partIndex >= 0 && suffixIndex > 0) { + suffixIndex-- + + if (partText[partIndex] !== suffix[suffixIndex]) { + return false + } + partIndex-- + } + index-- + } + + return suffixIndex === 0 +} const isYjsContentNode = (value: unknown): value is YjsNode => value instanceof Y.XmlElement || value instanceof Y.XmlText -const getRawYjsChildren = (node: Y.XmlElement): YjsNode[] => - node.toArray().filter((child): child is YjsNode => isYjsContentNode(child)) +const getRawYjsChildren = (node: Y.XmlElement): YjsNode[] => { + const rawChildren = node.toArray() + const children = new Array(rawChildren.length) + let writeIndex = 0 + let index = 0 + + while (index < rawChildren.length) { + const child = rawChildren[index] + + if (isYjsContentNode(child)) { + children[writeIndex] = child + writeIndex++ + } + index++ + } + + children.length = writeIndex + + return children +} + +const pushRawYjsChildren = (target: YjsNode[], node: Y.XmlElement): void => { + const rawChildren = node.toArray() + let index = 0 + + while (index < rawChildren.length) { + const child = rawChildren[index] + + if (isYjsContentNode(child)) { + target.push(child) + } + index++ + } +} + +const hasRawYjsChildren = (node: Y.XmlElement): boolean => { + const rawChildren = node.toArray() + let index = 0 + + while (index < rawChildren.length) { + const child = rawChildren[index] + + if (isYjsContentNode(child)) { + return true + } + index++ + } + + return false +} const isHiddenYjsNode = (node: YjsNode): boolean => getYjsAttributes(node)[HIDDEN_ATTRIBUTE] === true const isEmptyAttributeFreeYjsText = (node: YjsNode): boolean => node instanceof Y.XmlText && - getYjsTextContent(node).length === 0 && + getYjsLength(node) === 0 && !hasYjsAttributes(node) +const copyRecordAttributes = ( + target: YjsAttributeRecord, + source: Readonly, + skipKey?: string, + secondSkipKey?: string +): void => { + for (const key in source) { + if ( + !Object.hasOwn(source, key) || + key === skipKey || + key === secondSkipKey + ) { + continue + } + + target[key] = source[key] + } +} + type YjsVisibleChildSlot = { readonly node: YjsNode readonly rawIndex: number } type YjsChildRemovalMode = 'hidden' | 'hidden-parent' | 'visible' +type YjsNodeIdResolver = (id: string) => YjsNode | null const isVirtualYjsPlaceholder = (node: YjsNode): boolean => node instanceof Y.XmlElement && @@ -69,24 +260,33 @@ const hasRawYjsChildSlot = (slot: YjsVisibleChildSlot): boolean => const getVirtualYjsChild = ( root: Y.XmlElement, node: Y.XmlElement, - visited = new Set() + visited?: Set, + resolveNodeById?: YjsNodeIdResolver ): YjsNode | null => { - if (visited.has(node)) { - return null - } - - visited.add(node) - const virtualChildId = node.getAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) if (typeof virtualChildId === 'string') { - const virtualChild = findYjsNodeById(root, virtualChildId) + const nextVisited = visited ?? new Set() + + if (nextVisited.has(node)) { + return null + } + + nextVisited.add(node) + + const virtualChild = + resolveNodeById?.(virtualChildId) ?? findYjsNodeById(root, virtualChildId) if ( virtualChild instanceof Y.XmlElement && isVirtualYjsPlaceholder(virtualChild) ) { - return getVirtualYjsChild(root, virtualChild, visited) + return getVirtualYjsChild( + root, + virtualChild, + nextVisited, + resolveNodeById + ) } return virtualChild @@ -97,55 +297,288 @@ const getVirtualYjsChild = ( const getYjsVisibleChildSlots = ( root: Y.XmlElement, - node: Y.XmlElement + node: Y.XmlElement, + resolveNodeById?: YjsNodeIdResolver, + rawChildren = getRawYjsChildren(node) ): YjsVisibleChildSlot[] => { - const rawSlots = getRawYjsChildren(node).flatMap((child, rawIndex) => { + const rawSlots = new Array(rawChildren.length + 1) + let writeIndex = 0 + + if (!isVirtualYjsPlaceholder(node)) { + const virtualChild = getVirtualYjsChild( + root, + node, + undefined, + resolveNodeById + ) + + if (virtualChild !== null) { + rawSlots[writeIndex] = { + node: virtualChild, + rawIndex: VIRTUAL_YJS_CHILD_RAW_INDEX, + } + writeIndex++ + } + } + + let rawIndex = 0 + + while (rawIndex < rawChildren.length) { + const child = rawChildren[rawIndex] + + if (child === undefined) { + rawIndex++ + continue + } + if (isHiddenYjsNode(child)) { - return [] + rawIndex++ + continue } if (child instanceof Y.XmlElement && isVirtualYjsPlaceholder(child)) { - const virtualChild = getVirtualYjsChild(root, child) + const virtualChild = getVirtualYjsChild( + root, + child, + undefined, + resolveNodeById + ) + + if (virtualChild !== null) { + rawSlots[writeIndex] = { node: virtualChild, rawIndex } + writeIndex++ + } - return virtualChild === null ? [] : [{ node: virtualChild, rawIndex }] + rawIndex++ + continue } - return [{ node: child, rawIndex }] - }) + rawSlots[writeIndex] = { node: child, rawIndex } + writeIndex++ + rawIndex++ + } + + rawSlots.length = writeIndex + + return rawSlots +} + +const getYjsVisibleChildNodes = ( + root: Y.XmlElement, + node: Y.XmlElement, + resolveNodeById?: YjsNodeIdResolver, + rawChildren = getRawYjsChildren(node) +): YjsNode[] => { + const children = new Array(rawChildren.length + 1) + let writeIndex = 0 if (!isVirtualYjsPlaceholder(node)) { - const virtualChild = getVirtualYjsChild(root, node) + const virtualChild = getVirtualYjsChild( + root, + node, + undefined, + resolveNodeById + ) if (virtualChild !== null) { - return [ - { node: virtualChild, rawIndex: VIRTUAL_YJS_CHILD_RAW_INDEX }, - ...rawSlots, - ] + children[writeIndex] = virtualChild + writeIndex++ } } - return rawSlots + let rawIndex = 0 + + while (rawIndex < rawChildren.length) { + const child = rawChildren[rawIndex] + + if (child === undefined) { + rawIndex++ + continue + } + + if (isHiddenYjsNode(child)) { + rawIndex++ + continue + } + + if (child instanceof Y.XmlElement && isVirtualYjsPlaceholder(child)) { + const virtualChild = getVirtualYjsChild( + root, + child, + undefined, + resolveNodeById + ) + + if (virtualChild !== null) { + children[writeIndex] = virtualChild + writeIndex++ + } + + rawIndex++ + continue + } + + children[writeIndex] = child + writeIndex++ + rawIndex++ + } + + children.length = writeIndex + + return children } -export const getYjsChildren = (node: Y.XmlElement): YjsNode[] => - getRawYjsChildren(node).filter((child) => !isHiddenYjsNode(child)) +const getYjsVisibleChildSlotAt = ( + root: Y.XmlElement, + node: Y.XmlElement, + index: number, + resolveNodeById: YjsNodeIdResolver, + rawChildren: readonly unknown[] = node.toArray() +): YjsVisibleChildSlot | undefined => { + if (typeof index !== 'number' || index < 0) { + return undefined + } + + let visibleIndex = 0 + + if (!isVirtualYjsPlaceholder(node)) { + const virtualChild = getVirtualYjsChild( + root, + node, + undefined, + resolveNodeById + ) + + if (virtualChild !== null) { + if (visibleIndex === index) { + return { + node: virtualChild, + rawIndex: VIRTUAL_YJS_CHILD_RAW_INDEX, + } + } + + visibleIndex++ + } + } + + let rawIndex = 0 + + while (rawIndex < rawChildren.length) { + const child = rawChildren[rawIndex] + + if (!isYjsContentNode(child) || isHiddenYjsNode(child)) { + rawIndex++ + continue + } + + if (child instanceof Y.XmlElement && isVirtualYjsPlaceholder(child)) { + const virtualChild = getVirtualYjsChild( + root, + child, + undefined, + resolveNodeById + ) + + if (virtualChild !== null) { + if (visibleIndex === index) { + return { node: virtualChild, rawIndex } + } + + visibleIndex++ + } + + rawIndex++ + continue + } + + if (visibleIndex === index) { + return { node: child, rawIndex } + } + + visibleIndex++ + rawIndex++ + } + + return undefined +} + +const getYjsVisibleChildAt = ( + root: Y.XmlElement, + node: Y.XmlElement, + index: number, + resolveNodeById: YjsNodeIdResolver +): YjsNode | undefined => + getYjsVisibleChildSlotAt(root, node, index, resolveNodeById)?.node + +export const getYjsChildren = (node: Y.XmlElement): YjsNode[] => { + const rawChildren = node.toArray() + const children = new Array(rawChildren.length) + let writeIndex = 0 + let index = 0 + + while (index < rawChildren.length) { + const child = rawChildren[index] + + if (isYjsContentNode(child) && !isHiddenYjsNode(child)) { + children[writeIndex] = child + writeIndex++ + } + index++ + } + + children.length = writeIndex + + return children +} export const getYjsVisibleChildren = ( root: Y.XmlElement, node: Y.XmlElement -): YjsNode[] => getYjsVisibleChildSlots(root, node).map((slot) => slot.node) +): YjsNode[] => getYjsVisibleChildNodes(root, node) + +export type YjsVisibleChildrenReader = (node: Y.XmlElement) => YjsNode[] + +const getYjsVisibleChildrenWithResolver = ( + root: Y.XmlElement, + node: Y.XmlElement, + resolveNodeById: YjsNodeIdResolver +): YjsNode[] => getYjsVisibleChildNodes(root, node, resolveNodeById) + +export const getYjsVisibleChild = ( + root: Y.XmlElement, + node: Y.XmlElement, + index: number +): YjsNode | undefined => + getYjsVisibleChildAt(root, node, index, createLazyYjsNodeIdResolver(root)) + +export const hasYjsVisibleChildren = ( + root: Y.XmlElement, + node: Y.XmlElement +): boolean => getYjsVisibleChild(root, node, 0) !== undefined + +export const hasMultipleYjsVisibleChildren = ( + root: Y.XmlElement, + node: Y.XmlElement +): boolean => getYjsVisibleChild(root, node, 1) !== undefined + +export const createYjsVisibleChildrenReader = ( + root: Y.XmlElement +): YjsVisibleChildrenReader => { + const resolveNodeById = createLazyYjsNodeIdResolver(root) + + return (node) => + getYjsVisibleChildrenWithResolver(root, node, resolveNodeById) +} export const getYjsVisiblePath = ( root: Y.XmlElement, target: YjsNode ): Path | null => { - const visit = ( - node: YjsNode, - path: Path, - visited: Set - ): Path | null => { + const resolveNodeById = createLazyYjsNodeIdResolver(root) + const path: Path = [] + const visit = (node: YjsNode, visited: Set): Path | null => { if (node === target) { - return path + return copyPath(path) } if (!(node instanceof Y.XmlElement) || visited.has(node)) { return null @@ -153,20 +586,36 @@ export const getYjsVisiblePath = ( visited.add(node) - const children = getYjsVisibleChildren(root, node) + const children = getYjsVisibleChildrenWithResolver( + root, + node, + resolveNodeById + ) + + let index = 0 + + while (index < children.length) { + const child = children[index] + + if (child === undefined) { + index++ + continue + } - for (const [index, child] of children.entries()) { - const childPath = visit(child, [...path, index], visited) + path.push(index) + const childPath = visit(child, visited) + path.pop() if (childPath !== null) { return childPath } + index++ } return null } - return visit(root, [], new Set()) + return visit(root, new Set()) } export const createYjsText = ( @@ -175,6 +624,7 @@ export const createYjsText = ( ): Y.XmlText => { const yjsText = new Y.XmlText() + assertPublicYjsAttributesCanBeSet(attributes) setYjsAttributes(yjsText, attributes) if (text.length > 0) { @@ -186,28 +636,48 @@ export const createYjsText = ( export const createYjsNode = (node: Descendant): YjsNode => { if ('text' in node) { - const { text: value, ...attributes } = node - const stringValue = String(value) + const attributes: YjsAttributeRecord = {} - return createYjsText(stringValue, attributes) + copyRecordAttributes(attributes, node, 'text') + + return createYjsText(String(node.text), attributes) } - const { children, type, ...attributes } = node - const elementType = String(type ?? 'element') + const attributes: YjsAttributeRecord = {} + const elementType = String(node.type ?? 'element') const element = new Y.XmlElement(elementType) + copyRecordAttributes(attributes, node, 'children', 'type') + + assertPublicYjsAttributesCanBeSet(attributes) setYjsAttribute(element, SLATE_TYPE_ATTRIBUTE, elementType) setYjsAttributes(element, attributes) - if (children.length > 0) { - element.insert(0, createYjsNodes(children)) + if (node.children.length > 0) { + element.insert(0, createYjsNodes(node.children)) } return element } -export const createYjsNodes = (nodes: readonly Descendant[]): YjsNode[] => - nodes.map(createYjsNode) +export const createYjsNodes = (nodes: readonly Descendant[]): YjsNode[] => { + const yjsNodes = new Array(nodes.length) + + let index = 0 + + while (index < nodes.length) { + const node = nodes[index] + + if (node === undefined) { + throw new Error('Cannot create Yjs nodes from a sparse Slate node array.') + } + + yjsNodes[index] = createYjsNode(node) + index++ + } + + return yjsNodes +} export const replaceYjsChildren = ( parent: Y.XmlElement, @@ -215,6 +685,8 @@ export const replaceYjsChildren = ( ): void => { const length = getYjsLength(parent) + parent.removeAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) + if (length > 0) { parent.delete(0, length) } @@ -225,9 +697,26 @@ export const replaceYjsChildren = ( } export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => { - const children = getYjsVisibleChildren(root, root).map((node) => - readSlateNodeFromYjs(root, node) + const resolveNodeById = createLazyYjsNodeIdResolver(root) + const visibleChildren = getYjsVisibleChildrenWithResolver( + root, + root, + resolveNodeById ) + const children = new Array(visibleChildren.length) + + let index = 0 + + while (index < visibleChildren.length) { + const node = visibleChildren[index] + + if (node === undefined) { + throw new Error('Cannot read Slate value from a sparse Yjs node array.') + } + + children[index] = readSlateNodeFromYjs(root, node, resolveNodeById) + index++ + } return children.length > 0 ? children @@ -235,23 +724,38 @@ export const readSlateValueFromYjs = (root: Y.XmlElement): Descendant[] => { } export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement): void => { + const resolveNodeById = createLazyYjsNodeIdResolver(root) const visit = (parent: Y.XmlElement): void => { - for (const child of getRawYjsChildren(parent)) { + const rawChildren = getRawYjsChildren(parent) + let index = 0 + + while (index < rawChildren.length) { + const child = rawChildren[index] + if (child instanceof Y.XmlElement) { visit(child) } + index++ } - const visibleSlots = getYjsVisibleChildSlots(root, parent) + const visibleSlots = getYjsVisibleChildSlots( + root, + parent, + resolveNodeById, + rawChildren + ) if (visibleSlots.length <= 1) { return } - for (let index = visibleSlots.length - 1; index >= 0; index--) { - const slot = visibleSlots[index] + let slotIndex = visibleSlots.length - 1 + + while (slotIndex >= 0) { + const slot = visibleSlots[slotIndex] if (slot === undefined) { + slotIndex-- continue } @@ -260,18 +764,50 @@ export const removeRedundantEmptyYjsTextNodes = (root: Y.XmlElement): void => { if (hasRawYjsChildSlot(slot) && isEmptyAttributeFreeYjsText(child)) { parent.delete(slot.rawIndex, 1) } + slotIndex-- } } visit(root) } +const yjsAttributeRecordsEqual = ( + left: YjsAttributeRecord, + right: YjsAttributeRecord +): boolean => { + for (const key in left) { + if (!Object.hasOwn(left, key)) { + continue + } + + if (!areJsonLikeValuesEqual(left[key], right[key])) { + return false + } + } + + for (const key in right) { + if (!Object.hasOwn(right, key)) { + continue + } + + if (!areJsonLikeValuesEqual(left[key], right[key])) { + return false + } + } + + return true +} + const getUniformTextAttributes = (node: Y.XmlText): YjsAttributeRecord => { const delta = node.toDelta() let attributes: YjsAttributeRecord | undefined + let index = 0 + + while (index < delta.length) { + const part = delta[index] - for (const part of delta) { if (!isNonEmptyYjsTextDeltaPart(part)) { + index++ continue } @@ -279,19 +815,15 @@ const getUniformTextAttributes = (node: Y.XmlText): YjsAttributeRecord => { if (attributes === undefined) { attributes = partAttributes + index++ continue } - const keys = new Set([ - ...Object.keys(attributes), - ...Object.keys(partAttributes), - ]) - - for (const key of keys) { - if (attributes[key] !== partAttributes[key]) { - return {} - } + if (!yjsAttributeRecordsEqual(attributes, partAttributes)) { + return {} } + + index++ } return attributes ?? {} @@ -300,9 +832,22 @@ const getUniformTextAttributes = (node: Y.XmlText): YjsAttributeRecord => { const getPublicAttributes = ( attributes?: Readonly ): YjsAttributeRecord => { - const publicAttributes = { ...(attributes ?? {}) } + const publicAttributes: YjsAttributeRecord = {} + + if (attributes === undefined) { + return publicAttributes + } + + for (const key in attributes) { + if ( + !Object.hasOwn(attributes, key) || + INTERNAL_YJS_ATTRIBUTE_SET.has(key) + ) { + continue + } - deleteInternalAttributes(publicAttributes) + publicAttributes[key] = attributes[key] + } return publicAttributes } @@ -322,42 +867,67 @@ const getPublicYjsElementAttributes = ( const readSlateNodeFromYjs = ( root: Y.XmlElement, - node: YjsNode + node: YjsNode, + resolveNodeById: YjsNodeIdResolver ): Descendant => { if (node instanceof Y.XmlText) { const attributes = getPublicYjsAttributes(node) + const slateText: YjsAttributeRecord = {} - return { - ...attributes, - ...getUniformTextAttributes(node), - text: getYjsTextContent(node), - } + copyRecordAttributes(slateText, attributes) + copyRecordAttributes(slateText, getUniformTextAttributes(node)) + slateText.text = getYjsTextContent(node) + + return slateText as Descendant } const attributes = getPublicYjsElementAttributes(node) const type = getSlateYjsElementType(node) - - const children: Descendant[] = getYjsVisibleChildren(root, node).map( - (child) => readSlateNodeFromYjs(root, child) + const visibleChildren = getYjsVisibleChildrenWithResolver( + root, + node, + resolveNodeById ) + const children = new Array(visibleChildren.length) + + let index = 0 - return { - ...attributes, - type, - children: children.length > 0 ? children : [{ text: '' }], + while (index < visibleChildren.length) { + const child = visibleChildren[index] + + if (child === undefined) { + throw new Error('Cannot read Slate element children from a sparse array.') + } + + children[index] = readSlateNodeFromYjs(root, child, resolveNodeById) + index++ } + + const slateElement: YjsAttributeRecord = {} + + copyRecordAttributes(slateElement, attributes) + slateElement.type = type + slateElement.children = children.length > 0 ? children : [{ text: '' }] + + return slateElement as Descendant } const cloneYjsNodeWithRoot = ( node: YjsNode, - root: Y.XmlElement + root: Y.XmlElement, + resolveNodeById: YjsNodeIdResolver ): YjsNode | null => { if (node instanceof Y.XmlElement && isVirtualYjsPlaceholder(node)) { - const virtualChild = getVirtualYjsChild(root, node) + const virtualChild = getVirtualYjsChild( + root, + node, + undefined, + resolveNodeById + ) return virtualChild === null ? null - : cloneYjsNodeWithRoot(virtualChild, root) + : cloneYjsNodeWithRoot(virtualChild, root, resolveNodeById) } const attributes = getPublicYjsAttributes(node) @@ -372,12 +942,39 @@ const cloneYjsNodeWithRoot = ( } const clone = new Y.XmlElement(node.nodeName) - const children = getYjsChildren(node).flatMap((child) => { - const childClone = cloneYjsNodeWithRoot(child, root) + const rawChildren = getRawYjsChildren(node) + const visibleSlots = getYjsVisibleChildSlots( + root, + node, + resolveNodeById, + rawChildren + ) + const children = new Array(visibleSlots.length) + let writeIndex = 0 + let slotIndex = 0 + + while (slotIndex < visibleSlots.length) { + const slot = visibleSlots[slotIndex] + + if (slot === undefined) { + throw new Error('Cannot clone Yjs children from a sparse slot array.') + } + + const childClone = cloneYjsSlotWithRoot( + root, + slot, + rawChildren[slot.rawIndex], + resolveNodeById + ) - return childClone === null ? [] : [childClone] - }) + if (childClone !== null) { + children[writeIndex] = childClone + writeIndex++ + } + slotIndex++ + } + children.length = writeIndex setYjsAttributes(clone, attributes) if (children.length > 0) { @@ -390,46 +987,196 @@ const cloneYjsNodeWithRoot = ( export const cloneVisibleYjsNodes = ( root: Y.XmlElement, nodes: readonly YjsNode[] -): YjsNode[] => - nodes.flatMap((node) => { - const clone = cloneYjsNodeWithRoot(node, root) +): YjsNode[] => { + const resolveNodeById = createLazyYjsNodeIdResolver(root) + const clones = new Array(nodes.length) + let writeIndex = 0 + let index = 0 + + while (index < nodes.length) { + const node = nodes[index] + + if (node === undefined) { + throw new Error('Cannot clone visible Yjs nodes from a sparse array.') + } + + const clone = cloneYjsNodeWithRoot(node, root, resolveNodeById) - return clone === null ? [] : [clone] - }) + if (clone !== null) { + clones[writeIndex] = clone + writeIndex++ + } + index++ + } + + clones.length = writeIndex + + return clones +} + +const cloneYjsSlotWithRoot = ( + root: Y.XmlElement, + slot: YjsVisibleChildSlot, + rawChild: YjsNode | undefined, + resolveNodeById: YjsNodeIdResolver +): YjsNode | null => { + if ( + !hasRawYjsChildSlot(slot) || + (rawChild instanceof Y.XmlElement && isVirtualYjsPlaceholder(rawChild)) + ) { + return createVirtualYjsMovePlaceholder(slot.node) + } + + return cloneYjsNodeWithRoot(slot.node, root, resolveNodeById) +} + +export const splitVisibleYjsChildren = ( + root: Y.XmlElement, + parent: Y.XmlElement, + position: number +): YjsNode[] => { + const resolveNodeById = createLazyYjsNodeIdResolver(root) + const rawChildren = getRawYjsChildren(parent) + const visibleSlots = getYjsVisibleChildSlots( + root, + parent, + resolveNodeById, + rawChildren + ) + const rightChildren = new Array( + Math.max(visibleSlots.length - position, 0) + ) + let writeIndex = 0 + + let index = position + + while (index < visibleSlots.length) { + const slot = visibleSlots[index] + + if (slot === undefined) { + index++ + continue + } + + if (!hasRawYjsChildSlot(slot)) { + parent.removeAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) + rightChildren[writeIndex] = createVirtualYjsMovePlaceholder(slot.node) + writeIndex++ + index++ + + continue + } + + const childClone = cloneYjsSlotWithRoot( + root, + slot, + rawChildren[slot.rawIndex], + resolveNodeById + ) + + if (childClone !== null) { + rightChildren[writeIndex] = childClone + writeIndex++ + } + index++ + } + + rightChildren.length = writeIndex + + index = visibleSlots.length - 1 + + while (index >= position) { + const slot = visibleSlots[index] -export const getYjsNode = (root: Y.XmlElement, path: Path): YjsNode => { + if (slot !== undefined && hasRawYjsChildSlot(slot)) { + parent.delete(slot.rawIndex, 1) + } + index-- + } + + return rightChildren +} + +const getYjsNodeWithResolver = ( + root: Y.XmlElement, + path: Path, + resolveNodeById: YjsNodeIdResolver +): YjsNode => { let current: YjsNode = root + let pathIndex = 0 + + while (pathIndex < path.length) { + const index = path[pathIndex] + + if (typeof index !== 'number') { + throw new Error(`No Yjs node at path ${path.join('.')}`) + } - for (const index of path) { if (current instanceof Y.XmlText) { throw new Error(`Cannot descend into Y.XmlText at path ${path.join('.')}`) } - const child: YjsNode | undefined = getYjsVisibleChildren(root, current)[ - index - ] + const child: YjsNode | undefined = getYjsVisibleChildAt( + root, + current, + index, + resolveNodeById + ) if (!isYjsContentNode(child)) { throw new Error(`No Yjs node at path ${path.join('.')}`) } current = child + pathIndex++ } return current } -export const getYjsNodeIf = ( +const getYjsNodeWithResolverIf = ( root: Y.XmlElement, - path: Path + path: Path, + resolveNodeById: YjsNodeIdResolver ): YjsNode | null => { - try { - return getYjsNode(root, path) - } catch { - return null + let current: YjsNode = root + let pathIndex = 0 + + while (pathIndex < path.length) { + const index = path[pathIndex] + + if (typeof index !== 'number') { + return null + } + + if (current instanceof Y.XmlText) { + return null + } + + const child: YjsNode | undefined = getYjsVisibleChildAt( + root, + current, + index, + resolveNodeById + ) + + if (!isYjsContentNode(child)) { + return null + } + + current = child + pathIndex++ } + + return current } +export const getYjsNode = (root: Y.XmlElement, path: Path): YjsNode => + getYjsNodeWithResolver(root, path, createLazyYjsNodeIdResolver(root)) + +export const getYjsNodeIf = (root: Y.XmlElement, path: Path): YjsNode | null => + getYjsNodeWithResolverIf(root, path, createLazyYjsNodeIdResolver(root)) + export const setVirtualYjsMove = ( root: Y.XmlElement, target: YjsNode, @@ -464,9 +1211,34 @@ export const insertYjsChild = ( index: number, child: YjsNode ): void => { + if ( + getYjsLength(parent) === 0 && + typeof parent.getAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) !== 'string' + ) { + parent.insert(0, [child]) + + return + } + + const resolveNodeById = createLazyYjsNodeIdResolver(root) const rawChildren = getRawYjsChildren(parent) - const visibleSlots = getYjsVisibleChildSlots(root, parent) + const visibleSlots = getYjsVisibleChildSlots( + root, + parent, + resolveNodeById, + rawChildren + ) const visibleSlot = visibleSlots[index] + + if (visibleSlot?.rawIndex === VIRTUAL_YJS_CHILD_RAW_INDEX) { + // Parent-level virtual children have no raw slot; inserting before one + // requires materializing it as a placeholder after the inserted child. + parent.removeAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) + parent.insert(0, [child, createVirtualYjsMovePlaceholder(visibleSlot.node)]) + + return + } + const rawIndex = index >= visibleSlots.length || !visibleSlot ? rawChildren.length @@ -494,15 +1266,15 @@ export const setVirtualYjsUnwrapMove = ( target.removeAttribute(HIDDEN_ATTRIBUTE) wrapper.removeAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) - if (getRawYjsChildren(wrapper).length === 0) { - hideYjsNode(wrapper) - } else { + if (hasRawYjsChildren(wrapper)) { insertYjsChild( root, wrapperParent, wrapperIndex, createVirtualYjsMovePlaceholder(target) ) + } else { + hideYjsNode(wrapper) } } @@ -524,7 +1296,15 @@ export const removeYjsVirtualPlaceholderChild = ( index: number, target: YjsNode ): boolean => { - const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] + const resolveNodeById = createLazyYjsNodeIdResolver(root) + const rawChildren = getRawYjsChildren(parent) + const visibleSlot = getYjsVisibleChildSlotAt( + root, + parent, + index, + resolveNodeById, + rawChildren + ) if ( !visibleSlot || @@ -534,7 +1314,7 @@ export const removeYjsVirtualPlaceholderChild = ( return false } - const rawChild = getRawYjsChildren(parent)[visibleSlot.rawIndex] + const rawChild = rawChildren[visibleSlot.rawIndex] if ( !(rawChild instanceof Y.XmlElement) || @@ -554,23 +1334,47 @@ export const removeYjsChild = ( index: number, slateNode?: Descendant ): YjsChildRemovalMode => { - const visibleSlot = getYjsVisibleChildSlots(root, parent)[index] + const resolveNodeById = createLazyYjsNodeIdResolver(root) const rawChildren = getRawYjsChildren(parent) - const hiddenIndex = rawChildren.findIndex( - (child) => isHiddenYjsNode(child) && matchesSlateNode(child, slateNode) + const visibleSlot = getYjsVisibleChildSlotAt( + root, + parent, + index, + resolveNodeById, + rawChildren ) + let hiddenIndex: number | null = null + const getHiddenIndex = (): number => { + hiddenIndex ??= findHiddenYjsChildIndex( + root, + rawChildren, + slateNode, + resolveNodeById + ) + + return hiddenIndex + } if (visibleSlot !== undefined) { if (!hasRawYjsChildSlot(visibleSlot)) { - throw new Error('Cannot remove a virtual Yjs child from its parent.') + if ( + slateNode !== undefined && + !matchesSlateNode(visibleSlot.node, slateNode) + ) { + throw new Error('Cannot remove a virtual Yjs child from its parent.') + } + + parent.removeAttribute(VIRTUAL_CHILD_ID_ATTRIBUTE) + + return 'hidden' } if ( slateNode !== undefined && !matchesSlateNode(visibleSlot.node, slateNode) && - hiddenIndex !== -1 + getHiddenIndex() !== -1 ) { - parent.delete(hiddenIndex, 1) + parent.delete(getHiddenIndex(), 1) return 'hidden' } @@ -589,11 +1393,13 @@ export const removeYjsChild = ( return 'visible' } - if (hiddenIndex === -1) { + const matchingHiddenIndex = getHiddenIndex() + + if (matchingHiddenIndex === -1) { throw new Error('No Yjs child to remove at the requested visible path.') } - parent.delete(hiddenIndex, 1) + parent.delete(matchingHiddenIndex, 1) return 'hidden' } @@ -602,25 +1408,41 @@ export const getYjsParent = ( root: Y.XmlElement, path: Path ): { readonly index: number; readonly parent: Y.XmlElement } => { - const index = path.at(-1) + const index = lastPathIndex(path) if (index === undefined) { throw new Error('Cannot resolve a parent for the Yjs root.') } - const parentPath = path.slice(0, -1) - const parent = getYjsNode(root, parentPath) + const yjsParentPath = parentPath(path) + const parent = getYjsNode(root, yjsParentPath) if (parent instanceof Y.XmlText) { - throw new Error(`Yjs parent is text at path ${parentPath.join('.')}`) + throw new Error(`Yjs parent is text at path ${yjsParentPath.join('.')}`) } return { index, parent } } -const deleteInternalAttributes = (attributes: YjsAttributeRecord): void => { - for (const attribute of INTERNAL_YJS_ATTRIBUTES) { - delete attributes[attribute] +export const assertPublicYjsAttributeCanBeSet = (key: string): void => { + if (key === 'children' || key === 'text') { + throw new Error(`Cannot set the "${key}" property on a Yjs node.`) + } + + if (INTERNAL_YJS_ATTRIBUTE_SET.has(key)) { + throw new Error(`Cannot set internal Yjs attribute "${key}".`) + } +} + +const assertPublicYjsAttributesCanBeSet = ( + attributes: Readonly +): void => { + for (const key in attributes) { + if (!Object.hasOwn(attributes, key)) { + continue + } + + assertPublicYjsAttributeCanBeSet(key) } } @@ -658,8 +1480,100 @@ const matchesSlateNode = ( return getSlateYjsElementType(yjsNode) === String(slateNode.type ?? 'element') } +const matchesSlateNodeContent = ( + root: Y.XmlElement, + yjsNode: YjsNode, + slateNode: Descendant, + resolveNodeById: YjsNodeIdResolver +): boolean => { + if (!matchesSlateNode(yjsNode, slateNode)) { + return false + } + + if ('text' in slateNode) { + return ( + yjsNode instanceof Y.XmlText && + getYjsTextContent(yjsNode) === String(slateNode.text) + ) + } + + if (!(yjsNode instanceof Y.XmlElement)) { + return false + } + + const children = getYjsVisibleChildrenWithResolver( + root, + yjsNode, + resolveNodeById + ) + + if (children.length !== slateNode.children.length) { + return false + } + + let index = 0 + + while (index < slateNode.children.length) { + const child = slateNode.children[index] + const yjsChild = children[index] + + if ( + child === undefined || + yjsChild === undefined || + !matchesSlateNodeContent(root, yjsChild, child, resolveNodeById) + ) { + return false + } + index++ + } + + return true +} + +const findHiddenYjsChildIndex = ( + root: Y.XmlElement, + rawChildren: readonly YjsNode[], + slateNode: Descendant | undefined, + resolveNodeById: YjsNodeIdResolver +): number => { + if (slateNode === undefined) { + return -1 + } + + let candidateIndex = -1 + let candidateCount = 0 + + let index = 0 + + while (index < rawChildren.length) { + const child = rawChildren[index] + + if (child === undefined) { + index++ + continue + } + + if (!isHiddenYjsNode(child) || !matchesSlateNode(child, slateNode)) { + index++ + continue + } + + if (matchesSlateNodeContent(root, child, slateNode, resolveNodeById)) { + return index + } + + candidateIndex = index + candidateCount++ + index++ + } + + return candidateCount === 1 ? candidateIndex : -1 +} + const hasHiddenYjsDescendant = (node: Y.XmlElement): boolean => { - const stack = getRawYjsChildren(node) + const stack: YjsNode[] = [] + + pushRawYjsChildren(stack, node) for (let child = stack.pop(); child; child = stack.pop()) { if (isHiddenYjsNode(child)) { @@ -667,7 +1581,7 @@ const hasHiddenYjsDescendant = (node: Y.XmlElement): boolean => { } if (child instanceof Y.XmlElement) { - stack.push(...getRawYjsChildren(child)) + pushRawYjsChildren(stack, child) } } @@ -683,9 +1597,38 @@ const findYjsNodeById = (root: Y.XmlElement, id: string): YjsNode | null => { } if (node instanceof Y.XmlElement) { - stack.push(...getRawYjsChildren(node)) + pushRawYjsChildren(stack, node) } } return null } + +const createYjsNodeIdResolver = (root: Y.XmlElement): YjsNodeIdResolver => { + const nodesById = new Map() + const stack: YjsNode[] = [root] + + for (let node = stack.pop(); node; node = stack.pop()) { + const nodeId = node.getAttribute(NODE_ID_ATTRIBUTE) + + if (typeof nodeId === 'string') { + nodesById.set(nodeId, node) + } + + if (node instanceof Y.XmlElement) { + pushRawYjsChildren(stack, node) + } + } + + return (id) => nodesById.get(id) ?? null +} + +const createLazyYjsNodeIdResolver = (root: Y.XmlElement): YjsNodeIdResolver => { + let resolveNodeById: YjsNodeIdResolver | null = null + + return (id) => { + resolveNodeById ??= createYjsNodeIdResolver(root) + + return resolveNodeById(id) + } +} diff --git a/packages/slate-yjs/src/core/editor-adapter.ts b/packages/slate-yjs/src/core/editor-adapter.ts index f02311b3a3..43063903ec 100644 --- a/packages/slate-yjs/src/core/editor-adapter.ts +++ b/packages/slate-yjs/src/core/editor-adapter.ts @@ -4,7 +4,7 @@ import { Editor as EditorApi } from 'slate/internal' export type YjsEditorAdapter = { readonly importing: () => boolean - readonly readChildren: () => readonly Element[] + readonly readChildren: () => Element[] readonly readChildrenBeforeOperations: ( operations: readonly Operation[] ) => Element[] @@ -25,10 +25,24 @@ const remoteImportOptions = { const SELECTION_ROOT_TYPE = 'slate-yjs-selection-root' -const rangePoints = ( - range: Range -): readonly [Range['anchor'], Range['focus']] => - [range.anchor, range.focus] as const +const copyReadonlyArray = (items: readonly T[]): T[] => { + const copy = new Array(items.length) + + let index = 0 + + while (index < items.length) { + const item = items[index] + + if (item === undefined) { + throw new Error('Cannot copy a sparse array.') + } + + copy[index] = item + index++ + } + + return copy +} const sanitizeImportSelection = ( children: readonly Descendant[], @@ -38,11 +52,13 @@ const sanitizeImportSelection = ( return null } - const root: Element = { children: [...children], type: SELECTION_ROOT_TYPE } + const root: Element = { + children: copyReadonlyArray(children), + type: SELECTION_ROOT_TYPE, + } - return rangePoints(selection).every((point) => - isValidImportSelectionPoint(root, point) - ) + return isValidImportSelectionPoint(root, selection.anchor) && + isValidImportSelectionPoint(root, selection.focus) ? selection : null } @@ -64,8 +80,8 @@ const isValidImportSelectionPoint = ( export const createYjsEditorAdapter = (editor: Editor): YjsEditorAdapter => { let importing = false - const readChildren = (): readonly Element[] => - editor.read((state) => [...state.value.get().roots.main]) + const readChildren = (): Element[] => + editor.read((state) => copyReadonlyArray(state.value.get().roots.main)) const readChildrenBeforeOperations = ( operations: readonly Operation[] @@ -73,12 +89,29 @@ export const createYjsEditorAdapter = (editor: Editor): YjsEditorAdapter => { const baselineEditor = createEditor() EditorApi.replace(baselineEditor, { - children: [...readChildren()], + children: readChildren(), marks: null, selection: null, }) baselineEditor.update((tx) => { - tx.operations.replay([...operations].reverse().map(OperationApi.inverse)) + const inverseOperations = new Array(operations.length) + let inverseOperationCount = 0 + + let index = operations.length - 1 + + while (index >= 0) { + const operation = operations[index] + + if (operation !== undefined) { + inverseOperations[inverseOperationCount] = + OperationApi.inverse(operation) + inverseOperationCount++ + } + index-- + } + + inverseOperations.length = inverseOperationCount + tx.operations.replay(inverseOperations) }) return EditorApi.getSnapshot(baselineEditor).children @@ -95,7 +128,7 @@ export const createYjsEditorAdapter = (editor: Editor): YjsEditorAdapter => { try { editor.update((tx) => { tx.value.replace({ - children: [...children], + children: copyReadonlyArray(children), marks: null, selection: nextSelection, }) diff --git a/packages/slate-yjs/src/core/history.ts b/packages/slate-yjs/src/core/history.ts index 57852cc741..534fda20c7 100644 --- a/packages/slate-yjs/src/core/history.ts +++ b/packages/slate-yjs/src/core/history.ts @@ -1,5 +1,6 @@ import type { Editor, Operation } from 'slate' +import { areJsonLikeValuesEqual } from './json-equality' import { isRecord } from './record' type HistoryBatchLike = { @@ -28,33 +29,39 @@ const isHistoryState = (value: unknown): value is HistoryStateView => (value.history.undos === undefined || typeof value.history.undos === 'function'))) -const operationSignature = (operation: Operation): string => - JSON.stringify(operation) - -const operationMatchesSignature = ( - signature: string, - operation: Operation | undefined -): boolean => - operation !== undefined && signature === operationSignature(operation) +const operationsEqual = ( + left: Operation, + right: Operation | undefined +): boolean => right !== undefined && areJsonLikeValuesEqual(left, right) const isEmptyHistoryBatch = (batch: HistoryBatchLike): boolean => batch.operations?.length === 0 && (batch.statePatches?.length ?? 0) === 0 const getHistoryBatchOperationSuffixStart = ( batchOperations: readonly Operation[], - operationSignatures: readonly string[] + operations: readonly Operation[] ): number | null => { - if (batchOperations.length < operationSignatures.length) { + if (batchOperations.length < operations.length) { return null } - const start = batchOperations.length - operationSignatures.length + const start = batchOperations.length - operations.length + + let index = 0 + + while (index < operations.length) { + const operation = operations[index] - const matches = operationSignatures.every((signature, index) => - operationMatchesSignature(signature, batchOperations[start + index]) - ) + if ( + operation === undefined || + !operationsEqual(operation, batchOperations[start + index]) + ) { + return null + } + index++ + } - return matches ? start : null + return start } const readEditorHistory = (editor: Editor): HistoryLike | null => @@ -79,19 +86,24 @@ const removeOperationsFromHistoryStack = ( return } - const operationSignatures = operations.map(operationSignature) + let batchIndex = stack.length - 1 - for (let batchIndex = stack.length - 1; batchIndex >= 0; batchIndex -= 1) { + while (batchIndex >= 0) { const batch = stack[batchIndex] const batchOperations = batch?.operations + if (batch === undefined || batchOperations === undefined) { + batchIndex-- + continue + } + if (!Array.isArray(batchOperations)) { throw new Error('Cannot remove rejected Yjs operations from history.') } const start = getHistoryBatchOperationSuffixStart( batchOperations, - operationSignatures + operations ) if (start !== null) { @@ -103,6 +115,7 @@ const removeOperationsFromHistoryStack = ( return } + batchIndex-- } } @@ -110,6 +123,10 @@ export const removeRejectedYjsOperationsFromHistory = ( editor: Editor, operations: readonly Operation[] ): void => { + if (operations.length === 0) { + return + } + const history = readEditorHistory(editor) if (history === null) { @@ -124,6 +141,10 @@ export const removeRejectedYjsOperationsFromHistoryAfterCommit = ( editor: Editor, operations: readonly Operation[] ): void => { + if (operations.length === 0) { + return + } + const remove = (): void => { removeRejectedYjsOperationsFromHistory(editor, operations) } diff --git a/packages/slate-yjs/src/core/json-equality.ts b/packages/slate-yjs/src/core/json-equality.ts new file mode 100644 index 0000000000..182b95be85 --- /dev/null +++ b/packages/slate-yjs/src/core/json-equality.ts @@ -0,0 +1,73 @@ +import { isRecord } from './record' + +export const areJsonLikeValuesEqual = ( + left: unknown, + right: unknown +): boolean => { + if ( + left === right || + (typeof left === 'number' && + typeof right === 'number' && + Number.isNaN(left) && + Number.isNaN(right)) + ) { + return true + } + + if (Array.isArray(left) || Array.isArray(right)) { + if ( + !Array.isArray(left) || + !Array.isArray(right) || + left.length !== right.length + ) { + return false + } + + let index = 0 + + while (index < left.length) { + if (!areJsonLikeValuesEqual(left[index], right[index])) { + return false + } + index++ + } + + return true + } + + if (!isRecord(left) || !isRecord(right)) { + return false + } + + let leftDefinedKeyCount = 0 + + for (const key in left) { + if (!Object.hasOwn(left, key)) { + continue + } + + const leftValue = left[key] + + if (leftValue === undefined) { + continue + } + + if (!areJsonLikeValuesEqual(leftValue, right[key])) { + return false + } + + leftDefinedKeyCount++ + } + + let rightDefinedKeyCount = 0 + + for (const key in right) { + if (!Object.hasOwn(right, key) || right[key] === undefined) { + continue + } + + rightDefinedKeyCount++ + } + + return leftDefinedKeyCount === rightDefinedKeyCount +} diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index b78b444ef5..b0a39fcfcc 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -8,18 +8,19 @@ import { type YjsNode, } from './attributes' import { - cloneVisibleYjsNodes, createVirtualYjsMovePlaceholder, createYjsNode, createYjsNodes, createYjsText, - getYjsChildren, + createYjsVisibleChildrenReader, getYjsLength, getYjsNode, getYjsNodeIf, getYjsParent, - getYjsTextContent, - getYjsVisibleChildren, + getYjsTextContentFrom, + getYjsVisibleChild, + hasMultipleYjsVisibleChildren, + hasYjsVisibleChildren, hideYjsNode, insertYjsChild, isVirtualYjsChild, @@ -28,8 +29,10 @@ import { replaceYjsChildren, setVirtualYjsMove, setVirtualYjsUnwrapMove, + splitVisibleYjsChildren, + type YjsVisibleChildrenReader, } from './document' -import { pathsEqual } from './path' +import { lastPathIndex, parentPath, pathsEqual } from './path' import { isRecord } from './record' import { createSplitElement, @@ -45,19 +48,18 @@ const materializeEmptyYjsText = ( root: Y.XmlElement, path: Path ): Y.XmlText | null => { - const index = path.at(-1) + const index = lastPathIndex(path) if (index !== 0) { return null } - const parentPath = path.slice(0, -1) - const parent = getYjsNodeIf(root, parentPath) + const parent = getYjsNodeIf(root, parentPath(path)) if (!(parent instanceof Y.XmlElement)) { return null } - if (getYjsVisibleChildren(root, parent).length > 0) { + if (hasYjsVisibleChildren(root, parent)) { return null } @@ -93,7 +95,8 @@ type YjsTextPoint = { const resolveYjsTextPoint = ( root: Y.XmlElement, path: Path, - offset: number + offset: number, + readVisibleChildren: YjsVisibleChildrenReader ): YjsTextPoint | null => { const target = getYjsNode(root, path) @@ -102,10 +105,12 @@ const resolveYjsTextPoint = ( } const { index, parent } = getYjsParent(root, path) - const children = getYjsVisibleChildren(root, parent) + const children = readVisibleChildren(parent) let remainingOffset = offset - for (let childIndex = index; childIndex < children.length; childIndex++) { + let childIndex = index + + while (childIndex < children.length) { const child = children[childIndex] if (!(child instanceof Y.XmlText)) { @@ -119,6 +124,7 @@ const resolveYjsTextPoint = ( } remainingOffset -= length + childIndex++ } return null @@ -128,9 +134,10 @@ const deleteYjsTextRange = ( root: Y.XmlElement, path: Path, offset: number, - length: number + length: number, + readVisibleChildren: YjsVisibleChildrenReader ): void => { - const point = resolveYjsTextPoint(root, path, offset) + const point = resolveYjsTextPoint(root, path, offset, readVisibleChildren) if (point === null) { return @@ -139,9 +146,10 @@ const deleteYjsTextRange = ( let childIndex = point.childIndex let deleteOffset = point.offset let remainingLength = length + let children = readVisibleChildren(point.parent) while (remainingLength > 0) { - const child = getYjsVisibleChildren(root, point.parent)[childIndex] + const child = children[childIndex] if (!(child instanceof Y.XmlText)) { break @@ -158,12 +166,15 @@ const deleteYjsTextRange = ( root, point.parent, childIndex, - child + child, + readVisibleChildren ) } if (remainingLength > 0) { - if (!removedEmptyText) { + if (removedEmptyText) { + children = readVisibleChildren(point.parent) + } else { childIndex++ } deleteOffset = 0 @@ -172,18 +183,19 @@ const deleteYjsTextRange = ( } const isEmptyYjsText = (node: YjsNode): boolean => - node instanceof Y.XmlText && getYjsTextContent(node).length === 0 + node instanceof Y.XmlText && getYjsLength(node) === 0 const removeRedundantEmptyYjsText = ( root: Y.XmlElement, parent: Y.XmlElement, index: number, - text: Y.XmlText + text: Y.XmlText, + readVisibleChildren: YjsVisibleChildrenReader ): boolean => { if (!isEmptyYjsText(text) || hasYjsAttributes(text)) { return false } - if (getYjsVisibleChildren(root, parent).length <= 1) { + if (!hasMultipleYjsVisibleChildren(root, parent)) { return false } @@ -195,40 +207,55 @@ const removeRedundantEmptyYjsText = ( type YjsElementChildKind = 'element' | 'empty' | 'mixed' | 'text' const getYjsElementChildKind = ( - root: Y.XmlElement, - element: Y.XmlElement + children: readonly YjsNode[] ): YjsElementChildKind => { let kind: YjsElementChildKind = 'empty' + let index = 0 + + while (index < children.length) { + const child = children[index] + + if (child === undefined) { + throw new Error('Cannot read child kind from a sparse Yjs child array.') + } - for (const child of getYjsVisibleChildren(root, element)) { const childKind = child instanceof Y.XmlText ? 'text' : 'element' if (kind === 'empty') { kind = childKind + index++ continue } if (kind !== childKind) { return 'mixed' } + + index++ } return kind } const canMergeYjsElements = ( - root: Y.XmlElement, previous: Y.XmlElement, - target: Y.XmlElement + target: Y.XmlElement, + previousChildren: readonly YjsNode[], + targetChildren: readonly YjsNode[] ): boolean => { if (getSlateYjsElementType(previous) !== getSlateYjsElementType(target)) { return false } - const previousKind = getYjsElementChildKind(root, previous) - const targetKind = getYjsElementChildKind(root, target) + const previousKind = getYjsElementChildKind(previousChildren) + + if (previousKind === 'mixed') { + return false + } + + const targetKind = getYjsElementChildKind(targetChildren) - if (previousKind === 'mixed' || targetKind === 'mixed') { + if (targetKind === 'mixed') { return false } @@ -287,6 +314,13 @@ export const applySlateOperationToYjs = ( return null } + let readVisibleChildren: YjsVisibleChildrenReader | undefined + const getReadVisibleChildren = (): YjsVisibleChildrenReader => { + readVisibleChildren ??= createYjsVisibleChildrenReader(root) + + return readVisibleChildren + } + switch (operation.type) { case 'insert_text': { const text = getYjsTextForInsert(root, operation.path) @@ -304,7 +338,8 @@ export const applySlateOperationToYjs = ( root, operation.path, operation.offset, - operation.text.length + operation.text.length, + getReadVisibleChildren() ) return operationTrace(operation) @@ -334,7 +369,7 @@ export const applySlateOperationToYjs = ( const { index, parent } = getYjsParent(root, operation.path) if (target instanceof Y.XmlText) { - const rightText = getYjsTextContent(target).slice(operation.position) + const rightText = getYjsTextContentFrom(target, operation.position) if (rightText.length > 0) { target.delete(operation.position, rightText.length) @@ -350,16 +385,11 @@ export const applySlateOperationToYjs = ( return operationTrace(operation) } - const children = getYjsChildren(target) - const rightChildren = cloneVisibleYjsNodes( + const rightChildren = splitVisibleYjsChildren( root, - children.slice(operation.position) + target, + operation.position ) - const deleteCount = getYjsLength(target) - operation.position - - if (deleteCount > 0) { - target.delete(operation.position, deleteCount) - } insertYjsChild( root, @@ -381,7 +411,8 @@ export const applySlateOperationToYjs = ( throw new Error('Cannot merge the first Yjs child.') } - const children = getYjsVisibleChildren(root, parent) + const readVisibleChildren = getReadVisibleChildren() + const children = readVisibleChildren(parent) const previous = children[index - 1] const target = children[index] @@ -398,18 +429,35 @@ export const applySlateOperationToYjs = ( } if (previous instanceof Y.XmlElement && target instanceof Y.XmlElement) { - if (!canMergeYjsElements(root, previous, target)) { + const previousChildren = readVisibleChildren(previous) + const targetChildren = readVisibleChildren(target) + + if ( + !canMergeYjsElements( + previous, + target, + previousChildren, + targetChildren + ) + ) { return traceableFallback( operation, 'incompatible-structural-merge-elided' ) } - const previousHasVisibleChildren = - getYjsVisibleChildren(root, previous).length > 0 + const previousHasVisibleChildren = previousChildren.length > 0 + let targetIndex = 0 + + while (targetIndex < targetChildren.length) { + const moveTarget = targetChildren[targetIndex] + + if (moveTarget === undefined) { + throw new Error('Cannot merge from a sparse Yjs child array.') + } - for (const moveTarget of getYjsVisibleChildren(root, target)) { if (previousHasVisibleChildren && isEmptyYjsText(moveTarget)) { + targetIndex++ continue } @@ -419,6 +467,7 @@ export const applySlateOperationToYjs = ( getYjsLength(previous), createVirtualYjsMovePlaceholder(moveTarget) ) + targetIndex++ } removeYjsVirtualPlaceholderChild(root, parent, index, target) @@ -436,9 +485,10 @@ export const applySlateOperationToYjs = ( operation.type ) - const children = getYjsChildren(target) + const children = getReadVisibleChildren()(target) if ( replaceCompatibleYjsChildren( + root, children, operation.children, operation.newChildren @@ -474,32 +524,53 @@ export const applySlateOperationToYjs = ( operation.type ) - const existingChildren = getYjsVisibleChildren(root, target).slice( - operation.index, - operation.index + operation.children.length - ) - if ( replaceCompatibleYjsChildren( - existingChildren, + root, + getReadVisibleChildren()(target), operation.children, - operation.newChildren + operation.newChildren, + operation.index ) ) { return operationTrace(operation) } - const removalModes = operation.children.map((child) => - removeYjsChild(root, target, operation.index, child) - ) + let hasVirtualRemoval = false + let childIndex = 0 - const newChildren = createYjsNodes(operation.newChildren) + while (childIndex < operation.children.length) { + const child = operation.children[childIndex] - newChildren.forEach((child, offset) => { - insertYjsChild(root, target, operation.index + offset, child) - }) + if (child === undefined) { + throw new Error( + 'Cannot remove replace_children entries from a sparse child array.' + ) + } + + const removalMode = removeYjsChild(root, target, operation.index, child) - if (removalModes.some((mode) => mode !== 'visible')) { + if (removalMode !== 'visible') { + hasVirtualRemoval = true + } + childIndex++ + } + + if (operation.newChildren.length > 0) { + const newChildren = createYjsNodes(operation.newChildren) + + for (let offset = 0; offset < newChildren.length; offset++) { + const child = newChildren[offset] + + if (child === undefined) { + continue + } + + insertYjsChild(root, target, operation.index + offset, child) + } + } + + if (hasVirtualRemoval) { return traceableFallback(operation, 'replace-children-virtual-removal') } @@ -507,16 +578,16 @@ export const applySlateOperationToYjs = ( } case 'move_node': { const target = getYjsNodeIf(root, operation.path) - const sourceIndex = operation.path.at(-1) + const sourceIndex = lastPathIndex(operation.path) if (target === null) { return traceableFallback(operation, 'missing-move-source-elided') } - const sourceParentPath = operation.path.slice(0, -1) + const sourceParentPath = parentPath(operation.path) const sourceParent = getYjsNodeIf(root, sourceParentPath) - const newParentPath = operation.newPath.slice(0, -1) - const newIndex = operation.newPath.at(-1) + const newParentPath = parentPath(operation.newPath) + const newIndex = lastPathIndex(operation.newPath) const newParent = getYjsNodeIf(root, newParentPath) if ( @@ -574,19 +645,26 @@ export const applySlateOperationToYjs = ( target ) } - const newParentChildren = getYjsVisibleChildren(root, newParent) - const firstNewParentChild = newParentChildren[0] + const firstNewParentChild = getYjsVisibleChild(root, newParent, 0) + const hasMultipleNewParentChildren = + getYjsVisibleChild(root, newParent, 1) !== undefined + let removedEmptyNewParentChild = false if ( newIndex === 0 && - newParentChildren.length === 1 && - firstNewParentChild && + !hasMultipleNewParentChildren && + firstNewParentChild !== undefined && isEmptyYjsText(firstNewParentChild) ) { removeYjsChild(root, newParent, 0) + removedEmptyNewParentChild = true } - if (newIndex === 0 && getYjsLength(newParent) === 0) { + if ( + newIndex === 0 && + getYjsLength(newParent) === 0 && + (firstNewParentChild === undefined || removedEmptyNewParentChild) + ) { setVirtualYjsMove(root, target, newParent) removeSourceVirtualPlaceholder() diff --git a/packages/slate-yjs/src/core/path.ts b/packages/slate-yjs/src/core/path.ts index e7eaeb9420..fbd8cce4ce 100644 --- a/packages/slate-yjs/src/core/path.ts +++ b/packages/slate-yjs/src/core/path.ts @@ -1,18 +1,93 @@ import type { Path } from 'slate' +export const lastPathIndex = (path: Path): number | undefined => { + const index = path.length - 1 + + return index < 0 ? undefined : path[index] +} + +export const copyPath = (path: Path): Path => { + const copy = new Array(path.length) + + let index = 0 + + while (index < path.length) { + const pathValue = path[index] + + if (pathValue === undefined) { + throw new Error('Cannot copy an invalid path.') + } + + copy[index] = pathValue + index++ + } + + return copy +} + +export const parentPath = (path: Path): Path => { + const parent = new Array(Math.max(0, path.length - 1)) + + let index = 0 + + while (index < parent.length) { + const pathValue = path[index] + + if (pathValue === undefined) { + throw new Error('Cannot get a parent path for an invalid path.') + } + + parent[index] = pathValue + index++ + } + + return parent +} + export const nextPath = (path: Path): Path => { - const index = path.at(-1) + const lastIndex = path.length - 1 + const index = path[lastIndex] if (index === undefined) { throw new Error('Cannot get a next path for the root.') } - return [...path.slice(0, -1), index + 1] + const next = new Array(path.length) + + let pathIndex = 0 + + while (pathIndex < lastIndex) { + const pathValue = path[pathIndex] + + if (pathValue === undefined) { + throw new Error('Cannot get a next path for an invalid path.') + } + + next[pathIndex] = pathValue + pathIndex++ + } + + next[lastIndex] = index + 1 + + return next } export const pathsEqual = ( left: readonly number[], right: readonly number[] -): boolean => - left.length === right.length && - left.every((part, index) => part === right[index]) +): boolean => { + if (left.length !== right.length) { + return false + } + + let index = 0 + + while (index < left.length) { + if (left[index] !== right[index]) { + return false + } + index++ + } + + return true +} diff --git a/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts b/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts index 15fffc5e5b..e06a3a8c38 100644 --- a/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts +++ b/packages/slate-yjs/src/core/provider-lifecycle-adapter.ts @@ -13,7 +13,7 @@ import type { } from './types' type YjsProviderLifecycleAdapterOptions = { - readonly onConnectedChange: () => void + readonly onConnectedChange: (connected: boolean) => void readonly onProviderSyncedChange: () => void readonly provider?: YjsProviderLike } @@ -37,6 +37,10 @@ const PROVIDER_SYNC_EVENTS = [ ] as const satisfies readonly YjsProviderEvent[] const notifySubscribers = (subscribers: ReadonlySet<() => void>): void => { + if (subscribers.size === 0) { + return + } + for (const listener of subscribers) { listener() } @@ -64,25 +68,29 @@ export const createYjsProviderLifecycleAdapter = ({ notifySubscribers(subscribers) } - const setConnected = (nextConnected: boolean): void => { + const setConnected = (nextConnected: boolean): boolean => { if (connected === nextConnected) { - return + return false } connected = nextConnected - onConnectedChange() + onConnectedChange(connected) + + return true } const updateConnectedFromProviderStatus = ( status: YjsProviderStatus - ): void => { - setConnected(connectedFromYjsProviderStatus(status, connected)) - } + ): boolean => setConnected(connectedFromYjsProviderStatus(status, connected)) const updateProviderStatus = (status: YjsProviderStatus): void => { - updateConnectedFromProviderStatus(status) + const connectedChanged = updateConnectedFromProviderStatus(status) if (providerStatusValue === status) { + if (connectedChanged) { + updateProviderRevision() + } + return } @@ -104,6 +112,13 @@ export const createYjsProviderLifecycleAdapter = ({ const status = normalizeYjsProviderStatus(payload) if (status !== null) { + if ( + providerStatusValue === status && + isStaleConnectedProviderStatus(status, connected) + ) { + return + } + updateProviderStatus(status) } } @@ -131,7 +146,11 @@ export const createYjsProviderLifecycleAdapter = ({ } if (providerStatusValue === null) { - setConnected(fallbackConnected) + const connectedChanged = setConnected(fallbackConnected) + + if (connectedChanged) { + updateProviderRevision() + } } } @@ -155,15 +174,25 @@ export const createYjsProviderLifecycleAdapter = ({ const bind = (): void => { provider?.on?.('status', providerStatusObserver) - for (const event of PROVIDER_SYNC_EVENTS) { + let index = 0 + + while (index < PROVIDER_SYNC_EVENTS.length) { + const event = PROVIDER_SYNC_EVENTS[index] + provider?.on?.(event, providerSyncedObserver) + index++ } } const unbind = (): void => { provider?.off?.('status', providerStatusObserver) - for (const event of PROVIDER_SYNC_EVENTS) { + let index = 0 + + while (index < PROVIDER_SYNC_EVENTS.length) { + const event = PROVIDER_SYNC_EVENTS[index] + provider?.off?.(event, providerSyncedObserver) + index++ } } @@ -176,20 +205,34 @@ export const createYjsProviderLifecycleAdapter = ({ return result } - setConnected(true) + if (setConnected(true)) { + updateProviderRevision() + } } const disconnect = (): unknown => { if (provider !== undefined) { - setConnected(false) + const providerStatusBefore = providerStatusValue + const providerSyncedBefore = providerSyncedValue + const connectedChanged = setConnected(false) const result = provider.disconnect?.() syncProviderLifecycleResult(result, false) + if ( + connectedChanged && + providerStatusBefore === providerStatusValue && + providerSyncedBefore === providerSyncedValue + ) { + updateProviderRevision() + } + return result } - setConnected(false) + if (setConnected(false)) { + updateProviderRevision() + } } const reconnect = (): void => { diff --git a/packages/slate-yjs/src/core/replacement.ts b/packages/slate-yjs/src/core/replacement.ts index 90fe84dda6..0f3177c283 100644 --- a/packages/slate-yjs/src/core/replacement.ts +++ b/packages/slate-yjs/src/core/replacement.ts @@ -6,11 +6,16 @@ import { getSlateYjsElementType, removeSlateYjsAttribute, setSlateYjsAttribute, - setSlateYjsAttributes, type YjsAttributeRecord, type YjsNode, } from './attributes' -import { getYjsChildren, getYjsLength } from './document' +import { + assertPublicYjsAttributeCanBeSet, + createYjsVisibleChildrenReader, + getYjsLength, + type YjsVisibleChildrenReader, +} from './document' +import { areJsonLikeValuesEqual } from './json-equality' import { isRecord } from './record' type SlateElementLike = { @@ -21,14 +26,11 @@ type SlateTextLike = { readonly text: string } & Readonly> -const areJsonEqual = (left: unknown, right: unknown): boolean => - JSON.stringify(left) === JSON.stringify(right) - export const isNoopSlateOperationForYjs = (operation: Operation): boolean => { switch (operation.type) { case 'replace_children': case 'replace_fragment': - return areJsonEqual(operation.children, operation.newChildren) + return areJsonLikeValuesEqual(operation.children, operation.newChildren) default: return false } @@ -40,15 +42,29 @@ const isSlateText = (node: unknown): node is SlateTextLike => const isSlateElement = (node: unknown): node is SlateElementLike => isRecord(node) && Array.isArray(node.children) -const getTextAttributes = ({ - text: _text, - ...attributes -}: SlateTextLike): YjsAttributeRecord => attributes +const getTextAttributes = (node: SlateTextLike): YjsAttributeRecord => { + const attributes: YjsAttributeRecord = {} + + for (const key in node) { + if (Object.hasOwn(node, key) && key !== 'text') { + attributes[key] = node[key] + } + } + + return attributes +} + +const getElementAttributes = (node: SlateElementLike): YjsAttributeRecord => { + const attributes: YjsAttributeRecord = {} + + for (const key in node) { + if (Object.hasOwn(node, key) && key !== 'children') { + attributes[key] = node[key] + } + } -const getElementAttributes = ({ - children: _children, - ...attributes -}: SlateElementLike): YjsAttributeRecord => attributes + return attributes +} const applyTextFormatPatch = ( text: Y.XmlText, @@ -63,10 +79,23 @@ const applyTextFormatPatch = ( formatYjsTextAttributes(text, 0, length, patch) } -const assertYjsAttributeCanBeSet = (key: string): void => { - if (key === 'children' || key === 'text') { - throw new Error(`Cannot set the "${key}" property on a Yjs node.`) +const copyYjsChildren = (children: readonly YjsNode[]): YjsNode[] => { + const copy = new Array(children.length) + + let index = 0 + + while (index < children.length) { + const child = children[index] + + if (child === undefined) { + throw new Error('Cannot copy a sparse Yjs child array.') + } + + copy[index] = child + index++ } + + return copy } export const setYjsNodeAttributes = ( @@ -74,39 +103,64 @@ export const setYjsNodeAttributes = ( properties: YjsAttributeRecord, newProperties: YjsAttributeRecord ): void => { - const textPatch: YjsAttributeRecord = {} + const textNode = node instanceof Y.XmlText ? node : null + const textPatch: YjsAttributeRecord | null = textNode === null ? null : {} + let hasTextPatch = false + + for (const key in newProperties) { + if (!Object.hasOwn(newProperties, key)) { + continue + } - for (const [key, value] of Object.entries(newProperties)) { - assertYjsAttributeCanBeSet(key) + const value = newProperties[key] + + assertPublicYjsAttributeCanBeSet(key) if (value === null || value === undefined) { + if (properties[key] === null || properties[key] === undefined) { + continue + } + removeSlateYjsAttribute(node, key) - textPatch[key] = null + if (textPatch !== null) { + textPatch[key] = null + hasTextPatch = true + } + continue + } + + if (areJsonLikeValuesEqual(properties[key], value)) { continue } setSlateYjsAttribute(node, key, value) - if (node instanceof Y.XmlText) { + if (textPatch !== null) { textPatch[key] = value + hasTextPatch = true } } - for (const key of Object.keys(properties)) { + for (const key in properties) { + if (!Object.hasOwn(properties, key)) { + continue + } + if (Object.hasOwn(newProperties, key)) { continue } - assertYjsAttributeCanBeSet(key) + assertPublicYjsAttributeCanBeSet(key) removeSlateYjsAttribute(node, key) - if (node instanceof Y.XmlText) { + if (textPatch !== null) { textPatch[key] = null + hasTextPatch = true } } - if (node instanceof Y.XmlText && Object.keys(textPatch).length > 0) { - applyTextFormatPatch(node, textPatch) + if (textNode !== null && textPatch !== null && hasTextPatch) { + applyTextFormatPatch(textNode, textPatch) } } @@ -115,18 +169,31 @@ export const createSplitElement = ( properties: YjsAttributeRecord, children: readonly YjsNode[] ): Y.XmlElement => { - const { type: _type, ...attributes } = properties const elementType = typeof properties.type === 'string' ? properties.type : getSlateYjsElementType(original) const element = new Y.XmlElement(elementType) + for (const key in properties) { + if (!Object.hasOwn(properties, key) || key === 'type') { + continue + } + + assertPublicYjsAttributeCanBeSet(key) + } setSlateYjsAttribute(element, 'type', elementType) - setSlateYjsAttributes(element, attributes) + + for (const key in properties) { + if (!Object.hasOwn(properties, key) || key === 'type') { + continue + } + + setSlateYjsAttribute(element, key, properties[key]) + } if (children.length > 0) { - element.insert(0, [...children]) + element.insert(0, copyYjsChildren(children)) } return element @@ -155,9 +222,15 @@ const getSharedSuffixLength = ( while ( length < left.length - prefixLength && - length < right.length - prefixLength && - left.at(-1 - length) === right.at(-1 - length) + length < right.length - prefixLength ) { + const leftIndex = left.length - 1 - length + const rightIndex = right.length - 1 - length + + if (left[leftIndex] !== right[rightIndex]) { + break + } + length++ } @@ -170,38 +243,57 @@ const replaceYjsText = ( next: string, attributes: YjsAttributeRecord ): void => { + if (previous === next) { + return + } + const prefixLength = getSharedPrefixLength(previous, next) const suffixLength = getSharedSuffixLength(previous, next, prefixLength) const removeLength = previous.length - prefixLength - suffixLength - const insertText = next.slice(prefixLength, next.length - suffixLength) + const insertLength = next.length - prefixLength - suffixLength if (removeLength > 0) { text.delete(prefixLength, removeLength) } - if (insertText.length > 0) { + if (insertLength > 0) { + const insertText = next.slice(prefixLength, prefixLength + insertLength) + text.insert(prefixLength, insertText, attributes) } } const canReplaceCompatibleYjsChildren = ( + readVisibleChildren: YjsVisibleChildrenReader, children: readonly YjsNode[], oldChildren: readonly Descendant[], - newChildren: readonly Descendant[] + newChildren: readonly Descendant[], + startIndex = 0 ): boolean => { if ( - children.length !== oldChildren.length || - children.length !== newChildren.length + children.length - startIndex < oldChildren.length || + oldChildren.length !== newChildren.length ) { return false } - return children.every((child, index) => { + let index = 0 + + while (index < oldChildren.length) { + const child = children[startIndex + index] const oldChild = oldChildren[index] const newChild = newChildren[index] + if (child === undefined) { + return false + } + if (child instanceof Y.XmlText) { - return isSlateText(oldChild) && isSlateText(newChild) + if (!isSlateText(oldChild) || !isSlateText(newChild)) { + return false + } + index++ + continue } if ( @@ -209,33 +301,45 @@ const canReplaceCompatibleYjsChildren = ( isSlateElement(oldChild) && isSlateElement(newChild) ) { - return canReplaceCompatibleYjsChildren( - getYjsChildren(child), - oldChild.children, - newChild.children - ) + if ( + !canReplaceCompatibleYjsChildren( + readVisibleChildren, + readVisibleChildren(child), + oldChild.children, + newChild.children + ) + ) { + return false + } + + index++ + continue } return false - }) + } + + return true } -export const replaceCompatibleYjsChildren = ( +const applyCompatibleYjsChildrenReplacement = ( + readVisibleChildren: YjsVisibleChildrenReader, children: readonly YjsNode[], oldChildren: readonly Descendant[], - newChildren: readonly Descendant[] -): boolean => { - if (!canReplaceCompatibleYjsChildren(children, oldChildren, newChildren)) { - return false - } + newChildren: readonly Descendant[], + startIndex = 0 +): void => { + let index = 0 - children.forEach((child, index) => { + while (index < oldChildren.length) { + const child = children[startIndex + index] const oldChild = oldChildren[index] const newChild = newChildren[index] if (child instanceof Y.XmlText) { if (!isSlateText(oldChild) || !isSlateText(newChild)) { - return + index++ + continue } const attributes = getTextAttributes(newChild) @@ -243,7 +347,8 @@ export const replaceCompatibleYjsChildren = ( setYjsNodeAttributes(child, getTextAttributes(oldChild), attributes) replaceYjsText(child, oldChild.text, newChild.text, attributes) - return + index++ + continue } if ( @@ -256,13 +361,58 @@ export const replaceCompatibleYjsChildren = ( getElementAttributes(oldChild), getElementAttributes(newChild) ) - replaceCompatibleYjsChildren( - getYjsChildren(child), + applyCompatibleYjsChildrenReplacement( + readVisibleChildren, + readVisibleChildren(child), oldChild.children, newChild.children ) } - }) + index++ + } +} + +const replaceCompatibleYjsChildrenWithReader = ( + readVisibleChildren: YjsVisibleChildrenReader, + children: readonly YjsNode[], + oldChildren: readonly Descendant[], + newChildren: readonly Descendant[], + startIndex = 0 +): boolean => { + if ( + !canReplaceCompatibleYjsChildren( + readVisibleChildren, + children, + oldChildren, + newChildren, + startIndex + ) + ) { + return false + } + + applyCompatibleYjsChildrenReplacement( + readVisibleChildren, + children, + oldChildren, + newChildren, + startIndex + ) return true } + +export const replaceCompatibleYjsChildren = ( + root: Y.XmlElement, + children: readonly YjsNode[], + oldChildren: readonly Descendant[], + newChildren: readonly Descendant[], + startIndex = 0 +): boolean => + replaceCompatibleYjsChildrenWithReader( + createYjsVisibleChildrenReader(root), + children, + oldChildren, + newChildren, + startIndex + ) diff --git a/packages/slate-yjs/src/core/selection.ts b/packages/slate-yjs/src/core/selection.ts index 85fbc5fc23..a8d0f3589d 100644 --- a/packages/slate-yjs/src/core/selection.ts +++ b/packages/slate-yjs/src/core/selection.ts @@ -80,9 +80,14 @@ export const yjsRelativeRangeToSlateRange = ( range: YjsRelativeRange ): Range | null => { const anchor = yjsRelativePositionToSlatePoint(root, range.anchor) + + if (anchor === null) { + return null + } + const focus = yjsRelativePositionToSlatePoint(root, range.focus) - if (anchor === null || focus === null) { + if (focus === null) { return null } diff --git a/packages/slate-yjs/src/core/split-history-adapter.ts b/packages/slate-yjs/src/core/split-history-adapter.ts index 7ec5f7a1fe..6aeca53d95 100644 --- a/packages/slate-yjs/src/core/split-history-adapter.ts +++ b/packages/slate-yjs/src/core/split-history-adapter.ts @@ -3,26 +3,28 @@ import * as Y from 'yjs' import { toYjsAttributeRecord } from './attributes' import { + getYjsLength, getYjsNode, getYjsNodeIf, getYjsParent, - getYjsTextContent, + getYjsTextContentFrom, removeYjsChild, SPLIT_UNDO_TEXT_ATTRIBUTE, + yjsTextContentEndsWith, } from './document' import { applySlateOperationToYjs } from './operations' -import { nextPath, pathsEqual } from './path' +import { nextPath, parentPath, pathsEqual } from './path' import { appendElementText, clearSplitUndoTextAttribute, findSplitUndoTextRepairs, getTrailingSplitUndoText, - getVisibleText, isSplitHistory, type PendingTextSplitHistory, SPLIT_HISTORY_META, type SplitHistory, type SplitUndoTextRepair, + visibleTextStartsWith, } from './split-history' import type { YjsUndoManagerAdapter, @@ -64,9 +66,12 @@ const completeSplitHistory = ( pendingTextSplitHistory: PendingTextSplitHistory, elementSplit: SplitNodeOperation ): SplitHistory => ({ - ...pendingTextSplitHistory, + elementPath: pendingTextSplitHistory.elementPath, elementPosition: elementSplit.position, elementProperties: toYjsAttributeRecord(elementSplit.properties), + rightText: pendingTextSplitHistory.rightText, + textPath: pendingTextSplitHistory.textPath, + textProperties: pendingTextSplitHistory.textProperties, }) const peekSplit = ( @@ -93,26 +98,41 @@ export const createYjsSplitHistoryAdapter = ({ }: YjsSplitHistoryAdapterOptions): YjsSplitHistoryAdapter => { let pendingTextSplitHistory: PendingTextSplitHistory | null = null - const isTextSplitOperation = ( - operation: Operation - ): operation is SplitNodeOperation => - operation.type === 'split_node' && - getYjsNodeIf(root, operation.path) instanceof Y.XmlText - - const isElementSplitOperation = ( - operation: Operation - ): operation is SplitNodeOperation => - operation.type === 'split_node' && - !( - operation.path.length > 0 && - getYjsNodeIf(root, operation.path) instanceof Y.XmlText - ) - const createFromOperations = ( operations: readonly Operation[] ): SplitHistory | null => { - const textSplit = operations.find(isTextSplitOperation) - const elementSplit = operations.find(isElementSplitOperation) + let textSplit: SplitNodeOperation | undefined + let elementSplit: SplitNodeOperation | undefined + let operationIndex = 0 + + while (operationIndex < operations.length) { + const operation = operations[operationIndex] + + if (operation === undefined) { + throw new Error( + 'Cannot create split history from a sparse operation array.' + ) + } + + if (operation.type !== 'split_node') { + operationIndex++ + continue + } + + const isTextSplit = + getYjsNodeIf(root, operation.path) instanceof Y.XmlText + + if (isTextSplit) { + textSplit ??= operation + } else { + elementSplit ??= operation + } + + if (textSplit !== undefined && elementSplit !== undefined) { + break + } + operationIndex++ + } if (textSplit === undefined) { const pending = pendingTextSplitHistory @@ -130,7 +150,7 @@ export const createYjsSplitHistoryAdapter = ({ return null } - const elementPath = textSplit.path.slice(0, -1) + const elementPath = parentPath(textSplit.path) const text = getYjsNode(root, textSplit.path) if (!(text instanceof Y.XmlText)) { @@ -139,7 +159,7 @@ export const createYjsSplitHistoryAdapter = ({ const pending: PendingTextSplitHistory = { elementPath, - rightText: getYjsTextContent(text).slice(textSplit.position), + rightText: getYjsTextContentFrom(text, textSplit.position), textPath: textSplit.path, textProperties: toYjsAttributeRecord(textSplit.properties), } @@ -188,15 +208,14 @@ export const createYjsSplitHistoryAdapter = ({ throw new Error('Cannot redo split_node because the text node is gone.') } - const textValue = getYjsTextContent(text) - - if (!textValue.endsWith(redo.splitHistory.rightText)) { + if (!yjsTextContentEndsWith(text, redo.splitHistory.rightText)) { throw new Error( 'Cannot redo split_node because the right text is no longer at the split boundary.' ) } - const textPosition = textValue.length - redo.splitHistory.rightText.length + const textPosition = + getYjsLength(text) - redo.splitHistory.rightText.length const textSplit = createSplitNodeOperation( redo.splitHistory.textPath, textPosition, @@ -264,15 +283,13 @@ export const createYjsSplitHistoryAdapter = ({ } const hasRemoteSplitBoundary = (splitHistory: SplitHistory): boolean => { - try { - const rightElement = getYjsNode(root, nextPath(splitHistory.elementPath)) + const rightElement = getYjsNodeIf(root, nextPath(splitHistory.elementPath)) - return getVisibleText(root, rightElement).startsWith( - splitHistory.rightText - ) - } catch { + if (rightElement === null) { return false } + + return visibleTextStartsWith(root, rightElement, splitHistory.rightText) } const getSplitUndoTextRepair = ( @@ -282,42 +299,35 @@ export const createYjsSplitHistoryAdapter = ({ return null } - try { - const leftText = getYjsNode(root, splitHistory.textPath) + const leftText = getYjsNodeIf(root, splitHistory.textPath) - if (!(leftText instanceof Y.XmlText)) { - return null - } + if (!(leftText instanceof Y.XmlText)) { + return null + } - const trailing = getTrailingSplitUndoText(leftText) + const trailing = getTrailingSplitUndoText(leftText) - if (trailing === null || trailing.value !== splitHistory.rightText) { - return null - } - - return { - ...trailing, - hasRemoteSplitBoundary: hasRemoteSplitBoundary(splitHistory), - text: leftText, - } - } catch { + if (trailing === null || trailing.value !== splitHistory.rightText) { return null } + + return { + length: trailing.length, + offset: trailing.offset, + hasRemoteSplitBoundary: hasRemoteSplitBoundary(splitHistory), + text: leftText, + } } const leftTextEndsWithSplitRightText = ( splitHistory: SplitHistory ): boolean => { - try { - const leftText = getYjsNode(root, splitHistory.textPath) + const leftText = getYjsNodeIf(root, splitHistory.textPath) - return ( - leftText instanceof Y.XmlText && - getYjsTextContent(leftText).endsWith(splitHistory.rightText) - ) - } catch { - return false - } + return ( + leftText instanceof Y.XmlText && + yjsTextContentEndsWith(leftText, splitHistory.rightText) + ) } const repairAfterOfflineUndo = (): void => { @@ -330,7 +340,17 @@ export const createYjsSplitHistoryAdapter = ({ if (repairs.length > 0) { doc.transact(() => { - for (const repair of repairs) { + let repairIndex = 0 + + while (repairIndex < repairs.length) { + const repair = repairs[repairIndex] + + if (repair === undefined) { + throw new Error( + 'Cannot apply split repair from a sparse repair array.' + ) + } + if (repair.hasRemoteSplitBoundary) { repair.text.delete(repair.offset, repair.length) } else { @@ -340,6 +360,7 @@ export const createYjsSplitHistoryAdapter = ({ repair.length ) } + repairIndex++ } }, historyOrigin) } diff --git a/packages/slate-yjs/src/core/split-history.ts b/packages/slate-yjs/src/core/split-history.ts index 03b0d7bc5a..911229b9e4 100644 --- a/packages/slate-yjs/src/core/split-history.ts +++ b/packages/slate-yjs/src/core/split-history.ts @@ -7,10 +7,10 @@ import { type YjsNode, } from './attributes' import { + createYjsVisibleChildrenReader, getYjsLength, - getYjsTextContent, - getYjsVisibleChildren, SPLIT_UNDO_TEXT_ATTRIBUTE, + type YjsVisibleChildrenReader, } from './document' import { isRecord } from './record' import { isNonEmptyYjsTextDeltaPart } from './text-delta' @@ -49,8 +49,24 @@ type TrailingSplitUndoText = { const isSlateIndex = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0 -const isSlatePath = (value: unknown): value is Path => - Array.isArray(value) && value.every(isSlateIndex) +const isSlatePath = (value: unknown): value is Path => { + if (!Array.isArray(value)) { + return false + } + + let index = 0 + + while (index < value.length) { + const pathIndex = value[index] + + if (!isSlateIndex(pathIndex)) { + return false + } + index++ + } + + return true +} const isOptionalBoolean = (value: unknown): value is boolean | undefined => value === undefined || typeof value === 'boolean' @@ -61,6 +77,50 @@ const createTrailingSplitUndoText = ( ): TrailingSplitUndoText | null => value.length > 0 ? { length: value.length, offset, value } : null +const hasAttributes = ( + attributes: Readonly | undefined +): boolean => { + if (attributes === undefined) { + return false + } + + for (const key in attributes) { + if (Object.hasOwn(attributes, key)) { + return true + } + } + + return false +} + +const getTextInsertAttributes = ( + attributes: YjsAttributeRecord | undefined, + extraAttributes: YjsAttributeRecord, + extraAttributesHaveKeys = hasAttributes(extraAttributes) +): YjsAttributeRecord => { + if (!extraAttributesHaveKeys) { + return attributes ?? {} + } + if (!hasAttributes(attributes)) { + return extraAttributes + } + + const merged: YjsAttributeRecord = {} + + for (const key in attributes) { + if (Object.hasOwn(attributes, key)) { + merged[key] = attributes[key] + } + } + for (const key in extraAttributes) { + if (Object.hasOwn(extraAttributes, key)) { + merged[key] = extraAttributes[key] + } + } + + return merged +} + const appendTextContent = ( target: Y.XmlText, source: Y.XmlText, @@ -68,64 +128,107 @@ const appendTextContent = ( ): string => { let offset = getYjsLength(target) let insertedText = '' + const extraAttributesHaveKeys = hasAttributes(extraAttributes) + const sourceDelta = source.toDelta() + let index = 0 + + while (index < sourceDelta.length) { + const delta = sourceDelta[index] - for (const delta of source.toDelta()) { if (!isNonEmptyYjsTextDeltaPart(delta)) { + index++ continue } - target.insert(offset, delta.insert, { - ...(delta.attributes ?? {}), - ...extraAttributes, - }) + target.insert( + offset, + delta.insert, + getTextInsertAttributes( + delta.attributes, + extraAttributes, + extraAttributesHaveKeys + ) + ) offset += delta.insert.length insertedText += delta.insert + index++ } return insertedText } -export const appendElementText = ( - root: Y.XmlElement, +const appendElementTextWithReader = ( + readVisibleChildren: YjsVisibleChildrenReader, target: Y.XmlText, element: Y.XmlElement, extraAttributes: YjsAttributeRecord = {} ): string => { let insertedText = '' + const children = readVisibleChildren(element) + let index = 0 + + while (index < children.length) { + const child = children[index] + + if (child === undefined) { + throw new Error('Cannot append text from a sparse visible child array.') + } - for (const child of getYjsVisibleChildren(root, element)) { if (child instanceof Y.XmlText) { insertedText += appendTextContent(target, child, extraAttributes) } else { - insertedText += appendElementText(root, target, child, extraAttributes) + insertedText += appendElementTextWithReader( + readVisibleChildren, + target, + child, + extraAttributes + ) } + index++ } return insertedText } -const findLastVisibleText = ( +export const appendElementText = ( root: Y.XmlElement, + target: Y.XmlText, + element: Y.XmlElement, + extraAttributes: YjsAttributeRecord = {} +): string => + appendElementTextWithReader( + createYjsVisibleChildrenReader(root), + target, + element, + extraAttributes + ) + +const findLastVisibleText = ( + readVisibleChildren: YjsVisibleChildrenReader, node: YjsNode ): Y.XmlText | null => { if (node instanceof Y.XmlText) { return node } - const children = getYjsVisibleChildren(root, node) + const children = readVisibleChildren(node) + + let index = children.length - 1 - for (let index = children.length - 1; index >= 0; index--) { + while (index >= 0) { const child = children[index] if (child === undefined) { + index-- continue } - const text = findLastVisibleText(root, child) + const text = findLastVisibleText(readVisibleChildren, child) if (text !== null) { return text } + index-- } return null @@ -134,17 +237,23 @@ const findLastVisibleText = ( export const getTrailingSplitUndoText = ( text: Y.XmlText ): TrailingSplitUndoText | null => { + const delta = text.toDelta() let offset = getYjsLength(text) let value = '' - for (const delta of [...text.toDelta()].reverse()) { - if (!isNonEmptyYjsTextDeltaPart(delta)) { + let index = delta.length - 1 + + while (index >= 0) { + const part = delta[index] + + if (!isNonEmptyYjsTextDeltaPart(part)) { return createTrailingSplitUndoText(value, offset) } - if (delta.attributes?.[SPLIT_UNDO_TEXT_ATTRIBUTE] === true) { - offset -= delta.insert.length - value = delta.insert + value + if (part.attributes?.[SPLIT_UNDO_TEXT_ATTRIBUTE] === true) { + offset -= part.insert.length + value = part.insert + value + index-- continue } @@ -164,32 +273,112 @@ export const clearSplitUndoTextAttribute = ( }) } -export const getVisibleText = (root: Y.XmlElement, node: YjsNode): string => { - if (node instanceof Y.XmlText) { - return getYjsTextContent(node) +const visibleTextStartsWithReader = ( + readVisibleChildren: YjsVisibleChildrenReader, + node: YjsNode, + prefix: string +): boolean => { + if (prefix.length === 0) { + return true } - return getYjsVisibleChildren(root, node) - .map((child) => getVisibleText(root, child)) - .join('') + let prefixIndex = 0 + const visit = (current: YjsNode): boolean => { + if (prefixIndex === prefix.length) { + return true + } + + if (current instanceof Y.XmlText) { + const currentDelta = current.toDelta() + let deltaIndex = 0 + + while (deltaIndex < currentDelta.length) { + const part = currentDelta[deltaIndex] + + if (!isNonEmptyYjsTextDeltaPart(part)) { + deltaIndex++ + continue + } + + for ( + let index = 0; + index < part.insert.length && prefixIndex < prefix.length; + index++ + ) { + if (part.insert[index] !== prefix[prefixIndex]) { + return false + } + + prefixIndex++ + } + + if (prefixIndex === prefix.length) { + return true + } + deltaIndex++ + } + + return true + } + + const children = readVisibleChildren(current) + let childIndex = 0 + + while (childIndex < children.length) { + const child = children[childIndex] + + if (child === undefined) { + throw new Error( + 'Cannot compare text from a sparse visible child array.' + ) + } + + if (!visit(child)) { + return false + } + if (prefixIndex === prefix.length) { + return true + } + childIndex++ + } + + return true + } + + return visit(node) && prefixIndex === prefix.length } +export const visibleTextStartsWith = ( + root: Y.XmlElement, + node: YjsNode, + prefix: string +): boolean => + visibleTextStartsWithReader( + createYjsVisibleChildrenReader(root), + node, + prefix + ) + export const findSplitUndoTextRepairs = ( root: Y.XmlElement ): SplitUndoTextRepair[] => { const repairs: SplitUndoTextRepair[] = [] + const readVisibleChildren = createYjsVisibleChildrenReader(root) const visit = (parent: Y.XmlElement): void => { - const children = getYjsVisibleChildren(root, parent) + const children = readVisibleChildren(parent) + + let index = 0 - for (let index = 0; index < children.length; index++) { + while (index < children.length) { const left = children[index] if (!(left instanceof Y.XmlElement)) { + index++ continue } - const leftText = findLastVisibleText(root, left) + const leftText = findLastVisibleText(readVisibleChildren, left) const right = children[index + 1] const trailing = leftText === null ? null : getTrailingSplitUndoText(leftText) @@ -199,18 +388,28 @@ export const findSplitUndoTextRepairs = ( hasRemoteSplitBoundary: right === undefined ? false - : getVisibleText(root, right).startsWith(trailing.value), + : visibleTextStartsWithReader( + readVisibleChildren, + right, + trailing.value + ), length: trailing.length, offset: trailing.offset, text: leftText, }) } + index++ } - for (const child of children) { + let childIndex = 0 + + while (childIndex < children.length) { + const child = children[childIndex] + if (child instanceof Y.XmlElement) { visit(child) } + childIndex++ } } diff --git a/packages/slate-yjs/src/core/undo-manager-adapter.ts b/packages/slate-yjs/src/core/undo-manager-adapter.ts index a24667e723..6fd79fed80 100644 --- a/packages/slate-yjs/src/core/undo-manager-adapter.ts +++ b/packages/slate-yjs/src/core/undo-manager-adapter.ts @@ -24,15 +24,36 @@ const assertStack = ( value: unknown, name: string ): YjsUndoManagerStackItem[] => { - if (!Array.isArray(value) || value.some((item) => !isStackItem(item))) { + if (!Array.isArray(value)) { throw new Error( `Unsupported Yjs UndoManager ${name} contract. @slate/yjs pins yjs@${SUPPORTED_YJS_UNDO_MANAGER_VERSION}.` ) } + let index = 0 + + while (index < value.length) { + const item = value[index] + + if (!isStackItem(item)) { + throw new Error( + `Unsupported Yjs UndoManager ${name} contract. @slate/yjs pins yjs@${SUPPORTED_YJS_UNDO_MANAGER_VERSION}.` + ) + } + index++ + } + return value } +const peekStackItem = ( + stack: readonly YjsUndoManagerStackItem[] +): YjsUndoManagerStackItem | null => { + const lastIndex = stack.length - 1 + + return lastIndex < 0 ? null : (stack[lastIndex] ?? null) +} + const readUndoManagerStack = ( undoManager: Y.UndoManager, name: 'redo' | 'undo' @@ -80,16 +101,16 @@ export const createYjsUndoManagerAdapter = ( redo().push(item) }, peekRedo() { - return redo().at(-1) ?? null + return peekStackItem(redo()) }, peekUndo() { - return undo().at(-1) ?? null + return peekStackItem(undo()) }, redoDepth() { return redo().length }, storeUndoMeta(key: unknown, value: unknown) { - undo().at(-1)?.meta.set(key, value) + peekStackItem(undo())?.meta.set(key, value) }, } } diff --git a/packages/slate-yjs/src/react/index.ts b/packages/slate-yjs/src/react/index.ts index 1449979209..538a5fec81 100644 --- a/packages/slate-yjs/src/react/index.ts +++ b/packages/slate-yjs/src/react/index.ts @@ -66,13 +66,6 @@ export type UseYjsRemoteCursorOverlayPositionsOptions< readonly deps?: readonly unknown[] } -type YjsRemoteCursorRange< - TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, -> = { - readonly cursor: YjsRemoteCursor - readonly range: Range -} - const DEFAULT_CURSOR_DECORATION_SOURCE_ID = 'yjs-remote-cursors' const DOM_RECT_FIELDS = [ 'bottom', @@ -125,11 +118,22 @@ const createCursorData = < TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, >( cursor: YjsRemoteCursor -): YjsRemoteCursorDecorationData => ({ - clientId: cursor.clientId, - cursor, - ...(cursor.data === undefined ? {} : { data: cursor.data }), -}) +): YjsRemoteCursorDecorationData => { + const data: { + data?: TCursorData + clientId: number + cursor: YjsRemoteCursor + } = { + clientId: cursor.clientId, + cursor, + } + + if (cursor.data !== undefined) { + data.data = cursor.data + } + + return data +} const createDefaultCursorData = < TCursorData extends YjsRemoteCursorData, @@ -145,8 +149,22 @@ const isYjsDOMApi = (value: unknown): value is YjsDOMApi => typeof value.resolveRangeRect === 'function') const isDOMRectLike = (value: unknown): value is DOMRect => - isRecord(value) && - DOM_RECT_FIELDS.every((field) => typeof value[field] === 'number') + isRecord(value) && rectFieldsAreNumbers(value) + +const rectFieldsAreNumbers = (value: Record): boolean => { + let index = 0 + + while (index < DOM_RECT_FIELDS.length) { + const field = DOM_RECT_FIELDS[index] + + if (typeof value[field] !== 'number') { + return false + } + index++ + } + + return true +} const getYjsDOMApi = (editor: Editor): YjsDOMApi | undefined => { const api = isRecord(editor) ? editor.api : undefined @@ -191,7 +209,30 @@ const rectsEqual = (a: DOMRect | null, b: DOMRect | null): boolean => { return false } - return DOM_RECT_FIELDS.every((field) => a[field] === b[field]) + let index = 0 + + while (index < DOM_RECT_FIELDS.length) { + const field = DOM_RECT_FIELDS[index] + + if (a[field] !== b[field]) { + return false + } + index++ + } + + return true +} + +const countOwnEnumerableKeys = (value: Record): number => { + let count = 0 + + for (const key in value) { + if (Object.hasOwn(value, key)) { + count++ + } + } + + return count } const shallowEqual = (a: unknown, b: unknown): boolean => { @@ -202,13 +243,84 @@ const shallowEqual = (a: unknown, b: unknown): boolean => { return false } - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) + let keyCount = 0 - return ( - aKeys.length === bKeys.length && - aKeys.every((key) => Object.is(a[key], b[key])) - ) + for (const key in a) { + if (!Object.hasOwn(a, key)) { + continue + } + if (!Object.hasOwn(b, key) || !Object.is(a[key], b[key])) { + return false + } + keyCount++ + } + + return keyCount === countOwnEnumerableKeys(b) +} + +const isRemoteCursorLike = (value: unknown): value is YjsRemoteCursor => { + if ( + !isRecord(value) || + typeof value.clientId !== 'number' || + !('selection' in value) + ) { + return false + } + + for (const key in value) { + if (!Object.hasOwn(value, key)) { + continue + } + if (key !== 'clientId' && key !== 'data' && key !== 'selection') { + return false + } + } + + return true +} + +const remoteCursorsEqual = (a: unknown, b: unknown): boolean => + isRemoteCursorLike(a) && + isRemoteCursorLike(b) && + a.clientId === b.clientId && + shallowEqual(a.data, b.data) + +const overlayDataEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) { + return true + } + if (!isRecord(a) || !isRecord(b)) { + return false + } + + let keyCount = 0 + + for (const key in a) { + if (!Object.hasOwn(a, key)) { + continue + } + if (!Object.hasOwn(b, key)) { + return false + } + keyCount++ + if (key === 'cursor') { + if (!remoteCursorsEqual(a.cursor, b.cursor)) { + return false + } + continue + } + if (key === 'data' && isRecord(a.data) && isRecord(b.data)) { + if (!shallowEqual(a.data, b.data)) { + return false + } + continue + } + if (!Object.is(a[key], b[key])) { + return false + } + } + + return keyCount === countOwnEnumerableKeys(b) } const overlayPositionsEqual = < @@ -217,42 +329,32 @@ const overlayPositionsEqual = < >( a: readonly YjsRemoteCursorOverlayPosition[], b: readonly YjsRemoteCursorOverlayPosition[] -): boolean => - a.length === b.length && - a.every((position, index) => { - const next = b[index] - - return ( - next !== undefined && - position.clientId === next.clientId && - rangesEqual(position.range, next.range) && - rectsEqual(position.rect, next.rect) && - shallowEqual(position.data, next.data) - ) - }) +): boolean => { + if (a.length !== b.length) { + return false + } -const getRemoteCursorRange = < - TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, ->( - cursor: YjsRemoteCursor -): YjsRemoteCursorRange | null => { - const range = cursor.selection + let index = 0 - return range === null ? null : { cursor, range } -} + while (index < a.length) { + const position = a[index] + const next = b[index] -const readYjsRemoteCursorRanges = < - TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, ->( - editor: Editor -): readonly YjsRemoteCursorRange[] => - readYjsState(editor, (state) => - state.remoteCursors().flatMap((cursor) => { - const range = getRemoteCursorRange(cursor) + if ( + position === undefined || + next === undefined || + position.clientId !== next.clientId || + !rangesEqual(position.range, next.range) || + !rectsEqual(position.rect, next.rect) || + !overlayDataEqual(position.data, next.data) + ) { + return false + } + index++ + } - return range === null ? [] : [range] - }) - ) + return true +} const readYjsRemoteCursorOverlayPositions = < TCursorData extends YjsRemoteCursorData = YjsRemoteCursorData, @@ -261,19 +363,49 @@ const readYjsRemoteCursorOverlayPositions = < editor: Editor, options: UseYjsRemoteCursorOverlayPositionsOptions ): readonly YjsRemoteCursorOverlayPosition[] => - readYjsRemoteCursorRanges(editor).map(({ cursor, range }) => { - const data = - options.data === undefined - ? createDefaultCursorData(cursor) - : options.data(cursor) - - return { - clientId: cursor.clientId, - cursor, - data, - range, - rect: resolveCursorRect(editor, range), + readYjsState(editor, (state) => { + const cursors = state.remoteCursors() + const positions = new Array< + YjsRemoteCursorOverlayPosition + >(cursors.length) + let writeIndex = 0 + let index = 0 + + while (index < cursors.length) { + const cursor = cursors[index] + + if (cursor === undefined) { + throw new Error( + 'Cannot read overlay positions from a sparse cursor array.' + ) + } + + const range = cursor.selection + + if (range === null) { + index++ + continue + } + + const data = + options.data === undefined + ? createDefaultCursorData(cursor) + : options.data(cursor) + + positions[writeIndex] = { + clientId: cursor.clientId, + cursor, + data, + range, + rect: resolveCursorRect(editor, range), + } + writeIndex++ + index++ } + + positions.length = writeIndex + + return positions }) export const getYjsAwarenessRevision = (editor: Editor): number => @@ -351,8 +483,32 @@ export function useYjsRemoteCursorDecorationSource< createRangeDecorationSource(editor, { id, read: () => - readYjsRemoteCursorRanges(editor).map( - ({ cursor, range }) => { + readYjsState(editor, (state) => { + const cursors = state.remoteCursors() + const slices = new Array<{ + readonly data: TDecorationData + readonly key: string + readonly range: Range + }>(cursors.length) + let writeIndex = 0 + let index = 0 + + while (index < cursors.length) { + const cursor = cursors[index] + + if (cursor === undefined) { + throw new Error( + 'Cannot read decoration slices from a sparse cursor array.' + ) + } + + const range = cursor.selection + + if (range === null) { + index++ + continue + } + const decorate = optionsRef.current.decorate const data = decorate === undefined @@ -361,13 +517,19 @@ export function useYjsRemoteCursorDecorationSource< ) : decorate(cursor) - return { + slices[writeIndex] = { data, key: `${id}:${cursor.clientId}`, range, } + writeIndex++ + index++ } - ), + + slices.length = writeIndex + + return slices + }), }), [editor, id] ) @@ -413,12 +575,16 @@ export function useYjsRemoteCursorOverlayPositions< [editor] ) const [positions, setPositions] = useState(readPositions) + const positionsRef = useRef(positions) const refresh = useCallback(() => { const next = readPositions() - setPositions((current) => - overlayPositionsEqual(current, next) ? current : next - ) + if (overlayPositionsEqual(positionsRef.current, next)) { + return + } + + positionsRef.current = next + setPositions(next) }, [readPositions]) const cancelScheduledRefresh = useCallback(() => { if (typeof window === 'undefined' || animationFrameRef.current === null) { diff --git a/packages/slate-yjs/test/attributes-contract.spec.ts b/packages/slate-yjs/test/attributes-contract.spec.ts index 617fd681c0..f19d4e9609 100644 --- a/packages/slate-yjs/test/attributes-contract.spec.ts +++ b/packages/slate-yjs/test/attributes-contract.spec.ts @@ -1,8 +1,18 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' +import type { Descendant } from 'slate' import * as Y from 'yjs' -import { getYjsAttributes, setYjsAttribute } from '../src/core/attributes' +import { + getYjsAttributes, + setSlateYjsAttribute, + setYjsAttribute, +} from '../src/core/attributes' +import { createYjsNodes, readSlateValueFromYjs } from '../src/core/document' +import { + createSplitElement, + setYjsNodeAttributes, +} from '../src/core/replacement' describe('@slate/yjs attribute contract', () => { it('writes non-string Yjs attributes through the interop boundary', () => { @@ -19,4 +29,79 @@ describe('@slate/yjs attribute contract', () => { level: 2, }) }) + + it('preserves uniform object text attributes across separate Yjs delta parts', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const paragraph = new Y.XmlElement('paragraph') + const text = new Y.XmlText() + + setSlateYjsAttribute(paragraph, 'type', 'paragraph') + root.insert(0, [paragraph]) + paragraph.insert(0, [text]) + text.applyDelta( + [ + { attributes: { style: { color: 'red' } }, insert: 'a' }, + { attributes: { style: { color: 'red' } }, insert: 'b' }, + ], + { sanitize: false } + ) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { + children: [{ style: { color: 'red' }, text: 'ab' }], + type: 'paragraph', + }, + ]) + }) + + it('does not rewrite semantically unchanged object attributes', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const [text] = createYjsNodes([{ style: { color: 'red' }, text: 'alpha' }]) + let updates = 0 + + assert.ok(text instanceof Y.XmlText) + root.insert(0, [text]) + doc.on('update', () => { + updates++ + }) + + setYjsNodeAttributes( + text, + { style: { color: 'red' } }, + { style: { color: 'red' } } + ) + + assert.equal(updates, 0) + }) + + it('rejects Slate-authored attributes reserved for internal Yjs state', () => { + for (const key of ['slate:yjs-hidden', 'slate:type']) { + const node = { + children: [{ text: 'alpha' }], + [key]: true, + type: 'paragraph', + } as unknown as Descendant + + assert.throws( + () => createYjsNodes([node]), + new RegExp(`Cannot set internal Yjs attribute "${key}"`) + ) + } + }) + + it('rejects split-created element properties reserved for internal Yjs state', () => { + const original = new Y.XmlElement('paragraph') + + assert.throws( + () => + createSplitElement( + original, + { 'slate:yjs-hidden': true, type: 'paragraph' }, + [] + ), + /Cannot set internal Yjs attribute "slate:yjs-hidden"/ + ) + }) }) diff --git a/packages/slate-yjs/test/awareness-contract.spec.ts b/packages/slate-yjs/test/awareness-contract.spec.ts index d315f92d79..82eadacaab 100644 --- a/packages/slate-yjs/test/awareness-contract.spec.ts +++ b/packages/slate-yjs/test/awareness-contract.spec.ts @@ -167,6 +167,27 @@ describe('@slate/yjs awareness contract', () => { assert.equal(notifications, 2) }) + it('does not notify awareness subscribers for unchanged local cursor payloads', () => { + const { peer } = createAwarePeer() + const range = selection() + let notifications = 0 + const unsubscribe = subscribeYjsAwareness(peer, () => { + notifications += 1 + }) + + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range, { name: 'Ada' }) + }) + notifications = 0 + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range, { name: 'Ada' }) + }) + + assert.equal(notifications, 0) + + unsubscribe() + }) + it('rebases remote selections through virtual moved-node identity', () => { const { awareness, peer } = createAwarePeer() @@ -195,4 +216,19 @@ describe('@slate/yjs awareness contract', () => { selection: null, }) }) + + it('clears standalone awareness selection during editor cleanup', () => { + const { awareness, peer } = createAwarePeer() + + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(selection(), { name: 'B' }) + }) + + peer.cleanup() + + assert.deepEqual(awareness.getLocalState(), { + data: { name: 'B' }, + selection: null, + }) + }) }) diff --git a/packages/slate-yjs/test/history-contract.spec.ts b/packages/slate-yjs/test/history-contract.spec.ts new file mode 100644 index 0000000000..cea1a3db89 --- /dev/null +++ b/packages/slate-yjs/test/history-contract.spec.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { Editor, Operation } from 'slate' + +import { removeRejectedYjsOperationsFromHistory } from '../src/core/history' + +type HistoryBatchLike = { + operations?: Operation[] + statePatches?: readonly unknown[] +} + +const createHistoryEditor = (history: { + redos: HistoryBatchLike[] + undos: HistoryBatchLike[] +}): Editor => + ({ + read: (fn: (state: unknown) => unknown) => + fn({ + history: { + redos: () => history.redos, + undos: () => history.undos, + }, + }), + }) as Editor + +describe('@slate/yjs history contract', () => { + it('skips state-only history batches when removing rejected Yjs operations', () => { + const operation: Operation = { + offset: 0, + path: [0, 0], + text: '!', + type: 'insert_text', + } + const stateOnlyBatch = { statePatches: [{}] } + const history = { + redos: [], + undos: [{ operations: [operation] }, stateOnlyBatch], + } + + removeRejectedYjsOperationsFromHistory(createHistoryEditor(history), [ + operation, + ]) + + assert.deepEqual(history.undos, [stateOnlyBatch]) + }) + + it('matches rejected operations regardless of object key order', () => { + const operation: Operation = { + offset: 0, + path: [0, 0], + text: '!', + type: 'insert_text', + } + const historyOperation = { + text: '!', + path: [0, 0], + type: 'insert_text', + offset: 0, + } as Operation + const history = { + redos: [], + undos: [{ operations: [historyOperation] }], + } + + removeRejectedYjsOperationsFromHistory(createHistoryEditor(history), [ + operation, + ]) + + assert.deepEqual(history.undos, []) + }) +}) diff --git a/packages/slate-yjs/test/move-node-contract.spec.ts b/packages/slate-yjs/test/move-node-contract.spec.ts index b117512aaf..f7bfcd8c52 100644 --- a/packages/slate-yjs/test/move-node-contract.spec.ts +++ b/packages/slate-yjs/test/move-node-contract.spec.ts @@ -199,6 +199,36 @@ describe('@slate/yjs move_node collaboration contract', () => { ]) }) + it('moves a sibling before a leading virtual moved child', () => { + const peer = createPeer('b', undefined, [ + section(), + paragraph('moved'), + paragraph('before'), + ]) + + peer.editor.update((tx) => { + tx.nodes.move({ at: [1], to: [0, 0] }) + }) + const moved = getVisibleYjsNodeAt(peer, [0, 0]) + const before = getVisibleYjsNodeAt(peer, [1]) + + disconnectAndClearYjsTrace(peer) + peer.editor.update((tx) => { + tx.nodes.move({ at: [1], to: [0, 0] }) + }) + + assert.deepEqual(nestedTexts(peer), [['before', 'moved', '']]) + assert.equal(getVisibleYjsNodeAt(peer, [0, 0]), before) + assert.equal(getVisibleYjsNodeAt(peer, [0, 1]), moved) + assert.deepEqual(getYjsTrace(peer), [ + { + fallback: 'virtual-move-placeholder', + mode: 'traceable-fallback', + operationType: 'move_node', + }, + ]) + }) + it('preserves concurrent remote text when an offline cross-parent move reconnects', () => { const peers = createNestedPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts index 8e3fe058a1..e63feb7bfa 100644 --- a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts +++ b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts @@ -3,13 +3,41 @@ import { describe, it } from 'node:test' import type { Operation } from 'slate' import * as Y from 'yjs' -import { applySlateOperationToYjs } from '../src/core/operations' +import { + applySlateOperationToYjs, + isNoopSlateOperationForYjs, +} from '../src/core/operations' // The encoder still needs a runtime guard for operation types newer than this package. const futureSlateOperation = (type: string): Operation => ({ type }) as unknown as Operation describe('@slate/yjs operation encoder exhaustiveness contract', () => { + it('treats replace operations with equivalent object attributes as no-ops', () => { + const operation: Operation = { + children: [ + { + role: 'note', + children: [{ text: 'alpha' }], + type: 'paragraph', + }, + ], + newChildren: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + role: 'note', + }, + ], + newSelection: null, + path: [], + selection: null, + type: 'replace_fragment', + } + + assert.equal(isNoopSlateOperationForYjs(operation), true) + }) + it('treats selection operations as document-content no-ops', () => { const doc = new Y.Doc() const root = doc.get('slate', Y.XmlElement) diff --git a/packages/slate-yjs/test/provider-contract.spec.ts b/packages/slate-yjs/test/provider-contract.spec.ts index 1064711e35..db81de3548 100644 --- a/packages/slate-yjs/test/provider-contract.spec.ts +++ b/packages/slate-yjs/test/provider-contract.spec.ts @@ -45,6 +45,33 @@ const selection = (): Range => ({ focus: { path: [0, 0], offset: 3 }, }) +const linkAwareness = (source: FakeProvider, target: FakeProvider): Cleanup => { + const syncSourceAwareness = (): void => { + const state = source.awareness.getLocalState() + + if (source.status !== 'connected' || state === null) { + target.awareness.removeRemoteState(source.awareness.clientID) + + return + } + + target.awareness.setRemoteState(source.awareness.clientID, state) + } + const syncSourceStatus = (): void => { + if (source.status !== 'connected') { + target.awareness.removeRemoteState(source.awareness.clientID) + } + } + + source.awareness.on('change', syncSourceAwareness) + source.on('status', syncSourceStatus) + + return (): void => { + source.awareness.off('change', syncSourceAwareness) + source.off('status', syncSourceStatus) + } +} + class DeferredConnectProvider extends FakeProvider { override connect(): void { this.calls.push('connect') @@ -237,6 +264,26 @@ describe('@slate/yjs provider contract', () => { assert.equal(isYjsPeerConnected(peer), true) }) + it('notifies provider subscribers when local connection state changes without a provider', () => { + const peer = createYjsPeer({ + children: initialValue(), + clientId: 'a', + }) + const yjs = readEditorYjsState(peer.editor) + const seen: boolean[] = [] + const unsubscribe = yjs.subscribeProvider(() => { + seen.push(yjs.connected()) + }) + + runYjsUpdate(peer, (yjs) => { + yjs.disconnect() + yjs.connect() + }) + unsubscribe() + + assert.deepEqual(seen, [false, true]) + }) + it('uses provider doc and awareness as additive defaults', () => { const provider = new FakeProvider() seedProviderDoc(provider) @@ -592,6 +639,62 @@ describe('@slate/yjs provider contract', () => { cleanup() }) + it('rebroadcasts local awareness after reconnect when the selected range is unchanged', () => { + const doc = new Y.Doc() + const providerA = new FakeProvider({ + awarenessClientId: 101, + doc, + status: 'connected', + synced: true, + }) + const providerB = new FakeProvider({ + awarenessClientId: 202, + doc, + status: 'connected', + synced: true, + }) + seedProviderDoc(providerA) + const peerA = createProviderEditor(providerA) + const peerB = createProviderEditor(providerB) + const unlink = linkAwareness(providerA, providerB) + const range = selection() + + runEditorYjsUpdate(peerA.editor, (yjs) => { + yjs.sendSelection(range, { name: 'Ada' }) + }) + + assert.deepEqual(readEditorYjsState(peerB.editor).remoteCursors(), [ + { + clientId: 101, + data: { name: 'Ada' }, + selection: range, + }, + ]) + + runEditorYjsUpdate(peerA.editor, (yjs) => { + yjs.disconnect() + }) + + assert.deepEqual(readEditorYjsState(peerB.editor).remoteCursors(), []) + + runEditorYjsUpdate(peerA.editor, (yjs) => { + yjs.connect() + yjs.sendSelection(range, { name: 'Ada' }) + }) + + assert.deepEqual(readEditorYjsState(peerB.editor).remoteCursors(), [ + { + clientId: 101, + data: { name: 'Ada' }, + selection: range, + }, + ]) + + unlink() + peerA.cleanup() + peerB.cleanup() + }) + it('does not expose stale cursors while provider connect is pending', () => { const provider = new DeferredConnectProvider() seedProviderDoc(provider) @@ -672,6 +775,19 @@ describe('@slate/yjs provider contract', () => { assert.equal(yjs.providerStatus(), 'connected') assert.equal(yjs.connected(), false) + provider.emitStatus('connected') + + assert.equal(yjs.providerStatus(), 'connected') + assert.equal(yjs.connected(), false) + + runEditorYjsUpdate(editor, (yjs) => { + yjs.connect() + }) + + assert.deepEqual(provider.calls, ['disconnect', 'connect']) + assert.equal(yjs.providerStatus(), 'connected') + assert.equal(yjs.connected(), true) + cleanup() }) diff --git a/packages/slate-yjs/test/react-contract.spec.tsx b/packages/slate-yjs/test/react-contract.spec.tsx index bfd6415c09..d579151a40 100644 --- a/packages/slate-yjs/test/react-contract.spec.tsx +++ b/packages/slate-yjs/test/react-contract.spec.tsx @@ -338,6 +338,46 @@ describe('@slate/yjs react contract', () => { peer.cleanup() }) + it('keeps overlay positions stable across unrelated editor updates', () => { + const awareness = new FakeAwareness(8) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'h', + numericClientId: 8, + }) + let renders = 0 + + setEditorDomApi(peer.editor, { resolveRangeRect: () => null }) + + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { + const [positions] = useYjsRemoteCursorOverlayPositions(editor) + + renders += 1 + + return {positions.length} + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + }) + + const rendersAfterRemoteSelection = renders + + act(() => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [2, 0], offset: 'gamma'.length } }) + }) + }) + + assert.equal(renders, rendersAfterRemoteSelection) + + view.unmount() + peer.cleanup() + }) + it('refreshes remote cursor overlay data when overlay deps change', () => { const awareness = new FakeAwareness(5) const peer = createYjsPeer({ @@ -384,4 +424,53 @@ describe('@slate/yjs react contract', () => { view.unmount() peer.cleanup() }) + + it('refreshes custom overlay data when it has a cursor-named object', () => { + const awareness = new FakeAwareness(9) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'i', + numericClientId: 9, + }) + let setLabel: ((label: string) => void) | null = null + + setEditorDomApi(peer.editor, { resolveRangeRect: () => null }) + + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { + const [label, updateLabel] = React.useState('Ada') + const [positions] = useYjsRemoteCursorOverlayPositions< + { color: string; name: string }, + { cursor: { clientId: number; label: string } } + >(editor, { + data: (cursor) => ({ + cursor: { clientId: cursor.clientId, label }, + }), + deps: [label], + }) + + useEffect(() => { + setLabel = updateLabel + }, [updateLabel]) + + return {positions[0]?.data.cursor.label} + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + }) + + assert.equal(view.container.textContent, 'Ada') + + act(() => { + setLabel?.('Grace') + }) + + assert.equal(view.container.textContent, 'Grace') + + view.unmount() + peer.cleanup() + }) }) diff --git a/packages/slate-yjs/test/remove-node-contract.spec.ts b/packages/slate-yjs/test/remove-node-contract.spec.ts index b4bb5e7b36..b0b51fc275 100644 --- a/packages/slate-yjs/test/remove-node-contract.spec.ts +++ b/packages/slate-yjs/test/remove-node-contract.spec.ts @@ -1,6 +1,14 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Descendant } from 'slate' +import * as Y from 'yjs' +import { + createVirtualYjsMovePlaceholder, + createYjsText, + getYjsTextContent, + hideYjsNode, + removeYjsChild, +} from '../src/core/document' import { assertPeerTexts, connectYjsPeerAndSync, @@ -23,8 +31,11 @@ const initialValue = (): Descendant[] => [ paragraph('gamma'), ] -const createPeer = (clientId: string, seedUpdate?: Uint8Array): Peer => - createYjsPeer({ children: initialValue(), clientId, seedUpdate }) +const createPeer = ( + clientId: string, + seedUpdate?: Uint8Array, + children: readonly Descendant[] = initialValue() +): Peer => createYjsPeer({ children, clientId, seedUpdate }) const createPeers = (clientIds: readonly string[]): Peer[] => createSeededYjsPeers({ children: initialValue(), clientIds }) @@ -42,6 +53,93 @@ const insertRemoteText = (peer: Peer): void => { } describe('@slate/yjs remove_node collaboration contract', () => { + it('matches hidden text removals by text content', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const parent = new Y.XmlElement('paragraph') + const wrong = createYjsText('wrong', {}) + const right = createYjsText('right', {}) + + hideYjsNode(wrong) + hideYjsNode(right) + root.insert(0, [parent]) + parent.insert(0, [wrong, right]) + + removeYjsChild(root, parent, 0, { text: 'right' }) + + assert.deepEqual( + parent + .toArray() + .filter((node): node is Y.XmlText => node instanceof Y.XmlText) + .map(getYjsTextContent), + ['wrong'] + ) + }) + + it('matches hidden element removals by child content when candidates share a type', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const parent = new Y.XmlElement('root') + const wrong = new Y.XmlElement('paragraph') + const right = new Y.XmlElement('paragraph') + + wrong.insert(0, [createYjsText('wrong', {})]) + right.insert(0, [createYjsText('right', {})]) + hideYjsNode(wrong) + hideYjsNode(right) + root.insert(0, [parent]) + parent.insert(0, [wrong, right]) + + removeYjsChild(root, parent, 0, paragraph('right')) + + assert.deepEqual( + parent + .toArray() + .filter((node): node is Y.XmlElement => node instanceof Y.XmlElement) + .map((node) => + node + .toArray() + .filter((child): child is Y.XmlText => child instanceof Y.XmlText) + .map(getYjsTextContent) + .join('') + ), + ['wrong'] + ) + }) + + it('matches hidden element removals through virtual placeholder content', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const parent = new Y.XmlElement('root') + const movedText = createYjsText('right', {}) + const wrong = new Y.XmlElement('paragraph') + const right = new Y.XmlElement('paragraph') + + root.insert(0, [parent]) + parent.insert(0, [movedText]) + wrong.insert(0, [createYjsText('wrong', {})]) + right.insert(0, [createVirtualYjsMovePlaceholder(movedText)]) + hideYjsNode(wrong) + hideYjsNode(right) + parent.insert(1, [wrong, right]) + + removeYjsChild(root, parent, 0, paragraph('right')) + + assert.deepEqual( + parent + .toArray() + .filter((node): node is Y.XmlElement => node instanceof Y.XmlElement) + .map((node) => + node + .toArray() + .filter((child): child is Y.XmlText => child instanceof Y.XmlText) + .map(getYjsTextContent) + .join('') + ), + ['wrong'] + ) + }) + it('applies local offline remove_node without a root snapshot fallback', () => { const peer = createPeer('b') @@ -54,6 +152,38 @@ describe('@slate/yjs remove_node collaboration contract', () => { ]) }) + it('removes virtual moved content from its visible parent', () => { + const peer = createPeer('b', undefined, [ + { type: 'quote', children: [paragraph('left')] }, + { type: 'quote', children: [] }, + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [1, 0], + path: [2], + type: 'move_node', + }, + ]) + }) + + disconnectAndClearYjsTrace(peer) + peer.editor.update((tx) => { + tx.nodes.remove({ at: [1, 0] }) + }) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['left', '']) + assert.deepEqual(getYjsTrace(peer), [ + { + fallback: 'virtual-unwrap-wrapper-remove', + mode: 'traceable-fallback', + operationType: 'remove_node', + }, + ]) + }) + it('preserves concurrent remote sibling edits when an offline remove_node reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/replace-fragment-contract.spec.ts b/packages/slate-yjs/test/replace-fragment-contract.spec.ts index d689c494ed..055c7a1d92 100644 --- a/packages/slate-yjs/test/replace-fragment-contract.spec.ts +++ b/packages/slate-yjs/test/replace-fragment-contract.spec.ts @@ -10,10 +10,12 @@ import { disconnectAndClearYjsTrace, disconnectYjsPeer, getPeerTopLevelTexts, + getVisibleYjsNodeAt, getYjsNodeAt, getYjsTrace, type Peer, paragraph, + readPeerSlateValue, redoYjsPeer, redoYjsPeerAndSync, syncConnectedPeers, @@ -39,6 +41,11 @@ const multiLeafValue = (): Descendant[] => [ }, ] +const quote = (children: readonly Descendant[]): Descendant => ({ + type: 'quote', + children, +}) + const createPeer = ( clientId: ClientId, seedUpdate?: Uint8Array, @@ -133,6 +140,48 @@ const replayNoopRootReplaceFragment = (peer: Peer): void => { }) } +const moveParagraphIntoEmptyQuote = (peer: Peer): void => { + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [1, 0], + path: [2], + type: 'move_node', + }, + ]) + }) +} + +const replaceMovedQuoteText = (peer: Peer): void => { + const operation: Operation = { + children: [paragraph('moved')], + newChildren: [paragraph('moved!')], + newSelection: null, + path: [1], + selection: null, + type: 'replace_fragment', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + +const replaceMovedQuoteChildren = (peer: Peer): void => { + const operation: Operation = { + children: [paragraph('moved')], + newChildren: [paragraph('bravo'), paragraph('charlie')], + newSelection: null, + path: [1], + selection: null, + type: 'replace_fragment', + } + + peer.editor.update((tx) => { + tx.operations.replay([operation]) + }) +} + describe('@slate/yjs replace_fragment collaboration contract', () => { it('applies local offline single-text replace_fragment without replacing the Yjs text node', () => { const peer = createPeer('b') @@ -165,6 +214,51 @@ describe('@slate/yjs replace_fragment collaboration contract', () => { ]) }) + it('preserves virtual moved-node identity for compatible replace_fragment', () => { + const peer = createPeer('b', undefined, [ + quote([paragraph('left')]), + quote([]), + paragraph('moved'), + ]) + + moveParagraphIntoEmptyQuote(peer) + const movedParagraph = getVisibleYjsNodeAt(peer, [1, 0]) + + clearYjsTrace(peer) + replaceMovedQuoteText(peer) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['left', 'moved!']) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), movedParagraph) + assert.deepEqual(getYjsTrace(peer), [ + { mode: 'operation', operationType: 'replace_fragment' }, + ]) + }) + + it('replaces virtual moved children instead of appending beside them', () => { + const peer = createPeer('b', undefined, [ + quote([paragraph('left')]), + quote([]), + paragraph('moved'), + ]) + + moveParagraphIntoEmptyQuote(peer) + + clearYjsTrace(peer) + replaceMovedQuoteChildren(peer) + + assert.deepEqual(readPeerSlateValue(peer), [ + quote([paragraph('left')]), + quote([paragraph('bravo'), paragraph('charlie')]), + ]) + assert.deepEqual(getYjsTrace(peer), [ + { + fallback: 'replace-fragment-scoped-replace-identity-risk', + mode: 'traceable-fallback', + operationType: 'replace_fragment', + }, + ]) + }) + it('preserves concurrent remote text when an offline replace_fragment reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/simple-operations-contract.spec.ts b/packages/slate-yjs/test/simple-operations-contract.spec.ts index 30589730f1..08c999accd 100644 --- a/packages/slate-yjs/test/simple-operations-contract.spec.ts +++ b/packages/slate-yjs/test/simple-operations-contract.spec.ts @@ -34,9 +34,12 @@ const initialValue = (): Descendant[] => [ paragraph('gamma'), ] -const createPeer = (clientId: ClientId): Peer => +const createPeer = ( + clientId: ClientId, + children: readonly Descendant[] = initialValue() +): Peer => createYjsPeer({ - children: initialValue(), + children, clientId, numericClientId: clientIds[clientId], }) @@ -192,6 +195,35 @@ describe('@slate/yjs simple operation collaboration contract', () => { ]) }) + it('inserts before a leading virtual moved child', () => { + const peer = createPeer('b', [ + { type: 'quote', children: [] }, + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [0, 0], + path: [1], + type: 'move_node', + }, + ]) + }) + const movedParagraph = getVisibleYjsNodeAt(peer, [0, 0]) + + disconnectAndClearYjsTrace(peer) + peer.editor.update((tx) => { + tx.nodes.insert([paragraph('before')], { at: [0, 0] }) + }) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['beforemoved']) + assert.equal(getVisibleYjsNodeAt(peer, [0, 1]), movedParagraph) + assert.deepEqual(getYjsTrace(peer), [ + { mode: 'operation', operationType: 'insert_node' }, + ]) + }) + it('reconnects, undoes, and redoes insert_node while preserving remote edits', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers @@ -227,6 +259,90 @@ describe('@slate/yjs simple operation collaboration contract', () => { ]) }) + it('preserves virtual moved-node identity for compatible replace_children', () => { + const peer = createPeer('b', [ + { type: 'quote', children: [paragraph('left')] }, + { type: 'quote', children: [] }, + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [1, 0], + path: [2], + type: 'move_node', + }, + ]) + }) + const movedParagraph = getVisibleYjsNodeAt(peer, [1, 0]) + + disconnectAndClearYjsTrace(peer) + peer.editor.update((tx) => { + tx.operations.replay([ + { + children: [paragraph('moved')], + index: 0, + newChildren: [paragraph('moved!')], + newSelection: null, + path: [1], + selection: null, + type: 'replace_children', + }, + ]) + }) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['left', 'moved!']) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), movedParagraph) + assert.deepEqual(getYjsTrace(peer), [ + { mode: 'operation', operationType: 'replace_children' }, + ]) + }) + + it('replaces virtual moved children instead of throwing on removal', () => { + const peer = createPeer('b', [ + { type: 'quote', children: [paragraph('left')] }, + { type: 'quote', children: [] }, + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [1, 0], + path: [2], + type: 'move_node', + }, + ]) + }) + + disconnectAndClearYjsTrace(peer) + assert.doesNotThrow(() => { + peer.editor.update((tx) => { + tx.operations.replay([ + { + children: [paragraph('moved')], + index: 0, + newChildren: [paragraph('bravo'), paragraph('charlie')], + newSelection: null, + path: [1], + selection: null, + type: 'replace_children', + }, + ]) + }) + }) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['left', 'bravocharlie']) + assert.deepEqual(getYjsTrace(peer), [ + { + fallback: 'replace-children-virtual-removal', + mode: 'traceable-fallback', + operationType: 'replace_children', + }, + ]) + }) + it('reconnects, undoes, and redoes replace_children while preserving remote edits', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/split-merge-contract.spec.ts b/packages/slate-yjs/test/split-merge-contract.spec.ts index ec8b52d6e9..458e441f8a 100644 --- a/packages/slate-yjs/test/split-merge-contract.spec.ts +++ b/packages/slate-yjs/test/split-merge-contract.spec.ts @@ -8,6 +8,7 @@ import { createSeededYjsPeers, createYjsPeer, getPeerTopLevelTexts, + getVisibleYjsNodeAt, type Peer, paragraph, readPeerChildren, @@ -33,6 +34,11 @@ const quote = (children: readonly Descendant[]): Descendant => ({ children, }) +const section = (children: readonly Descendant[]): Descendant => ({ + type: 'section', + children, +}) + const initialValue = (): Descendant[] => [paragraph('Hello world!')] const createPeer = ( @@ -202,4 +208,43 @@ describe('@slate/yjs split and merge collaboration contract', () => { quote([paragraph('left'), paragraph('moved')]), ]) }) + + it('keeps nested parent-level virtual move content when splitting a grandparent element', () => { + const peer = createPeer('b', [ + section([quote([]), paragraph('right')]), + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [0, 0, 0], + path: [1], + type: 'move_node', + }, + ]) + }) + + assert.deepEqual(readPeerSlateValue(peer), [ + section([quote([paragraph('moved')]), paragraph('right')]), + ]) + const movedParagraph = getVisibleYjsNodeAt(peer, [0, 0, 0]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + path: [0], + position: 0, + properties: { type: 'section' }, + type: 'split_node', + }, + ]) + }) + + assert.deepEqual(readPeerSlateValue(peer), [ + section([{ text: '' }]), + section([quote([paragraph('moved')]), paragraph('right')]), + ]) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0, 0]), movedParagraph) + }) }) diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts index 7d834263ce..2b37f17430 100644 --- a/packages/slate-yjs/test/split-node-contract.spec.ts +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -17,6 +17,7 @@ import { disconnectAndClearYjsTrace, disconnectYjsPeer, getPeerTopLevelTexts, + getVisibleYjsNodeAt, getYjsNodeAt, getYjsTrace, type Peer, @@ -144,6 +145,84 @@ describe('@slate/yjs split_node collaboration contract', () => { ]) }) + it('splits virtual moved content by visible child position', () => { + const peer = createPeer('b', undefined, [ + { type: 'quote', children: [] }, + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [0, 0], + path: [1], + type: 'move_node', + }, + ]) + }) + const movedParagraph = getVisibleYjsNodeAt(peer, [0, 0]) + + disconnectAndClearYjsTrace(peer) + peer.editor.update((tx) => { + tx.operations.replay([ + { + path: [0], + position: 0, + properties: { type: 'quote' }, + type: 'split_node', + }, + ]) + }) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['', 'moved']) + assert.equal(getVisibleYjsNodeAt(peer, [1, 0]), movedParagraph) + assert.deepEqual(getYjsTrace(peer), [ + { mode: 'operation', operationType: 'split_node' }, + { mode: 'operation', operationType: 'insert_node' }, + ]) + }) + + it('splits raw children after a leading virtual moved child', () => { + const peer = createPeer('b', undefined, [ + { type: 'quote', children: [] }, + paragraph('moved'), + ]) + + peer.editor.update((tx) => { + tx.operations.replay([ + { + newPath: [0, 0], + path: [1], + type: 'move_node', + }, + { + node: paragraph('raw'), + path: [0, 1], + type: 'insert_node', + }, + ]) + }) + const movedParagraph = getVisibleYjsNodeAt(peer, [0, 0]) + + disconnectAndClearYjsTrace(peer) + peer.editor.update((tx) => { + tx.operations.replay([ + { + path: [0], + position: 1, + properties: { type: 'quote' }, + type: 'split_node', + }, + ]) + }) + + assert.deepEqual(getPeerTopLevelTexts(peer), ['moved', 'raw']) + assert.equal(getVisibleYjsNodeAt(peer, [0, 0]), movedParagraph) + assert.deepEqual(getYjsTrace(peer), [ + { mode: 'operation', operationType: 'split_node' }, + ]) + }) + it('preserves concurrent remote insert intent when an offline public split reconnects', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/scripts/benchmarks/README.md b/scripts/benchmarks/README.md index e92acd284c..4b07df4dd0 100644 --- a/scripts/benchmarks/README.md +++ b/scripts/benchmarks/README.md @@ -46,6 +46,7 @@ Current live family owners: - editor store/public snapshot surface - refs/projection - history retained memory +- Yjs collaboration ### `core/compare` @@ -124,6 +125,7 @@ Current artifact owners: - `tmp/slate-history-retained-memory.json` - `tmp/slate-editor-store-benchmark.json` - `tmp/slate-refs-projection-benchmark.json` +- `tmp/slate-yjs-collaboration-benchmark.json` - `tmp/slate-normalization-compare-benchmark.json` - `tmp/slate-core-observation-benchmark.json` - `tmp/slate-core-huge-document-benchmark.json` @@ -144,6 +146,7 @@ bun run bench:core:node-transforms:local bun run bench:core:text-selection:local bun run bench:core:editor-store:local bun run bench:core:refs-projection:local +bun run bench:core:yjs-collaboration:local bun run bench:core:clipboard-large-payload:local bun run bench:core:normalization:compare:local bun run bench:core:observation:compare:local diff --git a/scripts/benchmarks/core/current/yjs-collaboration.mjs b/scripts/benchmarks/core/current/yjs-collaboration.mjs index c631daa899..d938e76f91 100644 --- a/scripts/benchmarks/core/current/yjs-collaboration.mjs +++ b/scripts/benchmarks/core/current/yjs-collaboration.mjs @@ -218,20 +218,32 @@ const assertNoRootSnapshot = (peer) => { ) } -const measure = (run) => { - const samples = [] +const measurePhased = ({ verify, work }) => { + const totalSamples = [] + const verificationSamples = [] + const workSamples = [] for (let iteration = 0; iteration < iterations + 1; iteration += 1) { - const start = performance.now() - run() - const duration = performance.now() - start + const workStart = performance.now() + const context = work() + const workDuration = performance.now() - workStart + + const verificationStart = performance.now() + verify(context) + const verificationDuration = performance.now() - verificationStart if (iteration > 0) { - samples.push(duration) + workSamples.push(workDuration) + verificationSamples.push(verificationDuration) + totalSamples.push(workDuration + verificationDuration) } } - return summarize(samples) + return { + total: summarize(totalSamples), + verification: summarize(verificationSamples), + work: summarize(workSamples), + } } const insertDistributedText = (peer, ops, blocks, textPrefix) => { @@ -246,14 +258,19 @@ const insertDistributedText = (peer, ops, blocks, textPrefix) => { } const measureMultiEditorSync = () => - measure(() => { - const peers = createSeededPeers({ blocks: syncBlocks, prefix: 'sync' }) + measurePhased({ + verify: ({ peers }) => { + assertPeerTexts(peers) + assertNoRootSnapshot(peers[0]) + }, + work: () => { + const peers = createSeededPeers({ blocks: syncBlocks, prefix: 'sync' }) - insertDistributedText(peers[0], syncOps, syncBlocks, 's') - syncConnectedPeers(peers) + insertDistributedText(peers[0], syncOps, syncBlocks, 's') + syncConnectedPeers(peers) - assertPeerTexts(peers) - assertNoRootSnapshot(peers[0]) + return { peers } + }, }) const broadcastAwareness = (source, targets) => { @@ -272,78 +289,141 @@ const selection = (blockIndex, offset = 1) => ({ }) const measureAwarenessUpdates = () => - measure(() => { - const blocks = Math.max(1, Math.min(syncBlocks, awarenessUpdates)) - const peers = createSeededPeers({ - blocks, - prefix: 'awareness', - withAwareness: true, - }) - - for (let index = 0; index < awarenessUpdates; index += 1) { - const source = peers[index % peers.length] - const targets = peers.filter((peer) => peer !== source) - - runYjsUpdate(source, (yjs) => { - yjs.sendSelection(selection(index % blocks), { - name: source.id, - update: index, - }) + measurePhased({ + verify: ({ peers }) => { + for (const peer of peers) { + assert.equal(getYjsState(peer).remoteCursors().length, peerCount - 1) + } + }, + work: () => { + const blocks = Math.max(1, Math.min(syncBlocks, awarenessUpdates)) + const peers = createSeededPeers({ + blocks, + prefix: 'awareness', + withAwareness: true, }) - broadcastAwareness(source, targets) - } - for (const peer of peers) { - assert.equal(getYjsState(peer).remoteCursors().length, peerCount - 1) - } + for (let index = 0; index < awarenessUpdates; index += 1) { + const source = peers[index % peers.length] + const targets = peers.filter((peer) => peer !== source) + + runYjsUpdate(source, (yjs) => { + yjs.sendSelection(selection(index % blocks), { + name: source.id, + update: index, + }) + }) + broadcastAwareness(source, targets) + } + + return { peers } + }, }) const measureReconnect = () => - measure(() => { - const peers = createSeededPeers({ blocks: syncBlocks, prefix: 'reconnect' }) - const [online, offline] = peers + measurePhased({ + verify: ({ offline, peers }) => { + assertPeerTexts(peers) + assertNoRootSnapshot(offline) + }, + work: () => { + const peers = createSeededPeers({ + blocks: syncBlocks, + prefix: 'reconnect', + }) + const [online, offline] = peers - runYjsUpdate(offline, (yjs) => yjs.disconnect()) - insertDistributedText(offline, reconnectOps, syncBlocks, 'o') - insertDistributedText(online, reconnectOps, syncBlocks, 'r') - syncConnectedPeers(peers) + runYjsUpdate(offline, (yjs) => yjs.disconnect()) + insertDistributedText(offline, reconnectOps, syncBlocks, 'o') + insertDistributedText(online, reconnectOps, syncBlocks, 'r') + syncConnectedPeers(peers) - runYjsUpdate(offline, (yjs) => yjs.connect()) - syncConnectedPeers(peers) + runYjsUpdate(offline, (yjs) => yjs.connect()) + syncConnectedPeers(peers) - assertPeerTexts(peers) - assertNoRootSnapshot(offline) + return { offline, peers } + }, }) const measureLargeDocSync = () => - measure(() => { - const peers = createSeededPeers({ blocks: largeBlocks, prefix: 'large' }) + measurePhased({ + verify: ({ peers }) => { + assertPeerTexts(peers) + assertNoRootSnapshot(peers[0]) + }, + work: () => { + const peers = createSeededPeers({ blocks: largeBlocks, prefix: 'large' }) - insertDistributedText(peers[0], largeOps, largeBlocks, 'l') - syncConnectedPeers(peers) + insertDistributedText(peers[0], largeOps, largeBlocks, 'l') + syncConnectedPeers(peers) - assertPeerTexts(peers) - assertNoRootSnapshot(peers[0]) + return { peers } + }, }) +const measuredLanes = { + multiEditorSync: measureMultiEditorSync(), + awarenessUpdates: measureAwarenessUpdates(), + reconnect: measureReconnect(), + largeDocSync: measureLargeDocSync(), +} + const lanes = { - multiEditorSyncMs: measureMultiEditorSync(), - awarenessUpdatesMs: measureAwarenessUpdates(), - reconnectMs: measureReconnect(), - largeDocSyncMs: measureLargeDocSync(), + multiEditorSyncMs: measuredLanes.multiEditorSync.total, + awarenessUpdatesMs: measuredLanes.awarenessUpdates.total, + reconnectMs: measuredLanes.reconnect.total, + largeDocSyncMs: measuredLanes.largeDocSync.total, +} + +const workLanes = { + multiEditorSyncWorkMs: measuredLanes.multiEditorSync.work, + awarenessUpdatesWorkMs: measuredLanes.awarenessUpdates.work, + reconnectWorkMs: measuredLanes.reconnect.work, + largeDocSyncWorkMs: measuredLanes.largeDocSync.work, +} + +const verificationLanes = { + multiEditorSyncVerificationMs: measuredLanes.multiEditorSync.verification, + awarenessUpdatesVerificationMs: measuredLanes.awarenessUpdates.verification, + reconnectVerificationMs: measuredLanes.reconnect.verification, + largeDocSyncVerificationMs: measuredLanes.largeDocSync.verification, } const metrics = { yjs_multi_editor_sync_p95_ms: lanes.multiEditorSyncMs.p95, + yjs_multi_editor_sync_work_p95_ms: workLanes.multiEditorSyncWorkMs.p95, + yjs_multi_editor_sync_verification_p95_ms: + verificationLanes.multiEditorSyncVerificationMs.p95, yjs_awareness_updates_p95_ms: lanes.awarenessUpdatesMs.p95, + yjs_awareness_updates_work_p95_ms: workLanes.awarenessUpdatesWorkMs.p95, + yjs_awareness_updates_verification_p95_ms: + verificationLanes.awarenessUpdatesVerificationMs.p95, yjs_reconnect_p95_ms: lanes.reconnectMs.p95, + yjs_reconnect_work_p95_ms: workLanes.reconnectWorkMs.p95, + yjs_reconnect_verification_p95_ms: + verificationLanes.reconnectVerificationMs.p95, yjs_large_doc_sync_p95_ms: lanes.largeDocSyncMs.p95, + yjs_large_doc_sync_work_p95_ms: workLanes.largeDocSyncWorkMs.p95, + yjs_large_doc_sync_verification_p95_ms: + verificationLanes.largeDocSyncVerificationMs.p95, yjs_collaboration_worst_p95_ms: Math.max( lanes.multiEditorSyncMs.p95, lanes.awarenessUpdatesMs.p95, lanes.reconnectMs.p95, lanes.largeDocSyncMs.p95 ), + yjs_collaboration_worst_work_p95_ms: Math.max( + workLanes.multiEditorSyncWorkMs.p95, + workLanes.awarenessUpdatesWorkMs.p95, + workLanes.reconnectWorkMs.p95, + workLanes.largeDocSyncWorkMs.p95 + ), + yjs_collaboration_worst_verification_p95_ms: Math.max( + verificationLanes.multiEditorSyncVerificationMs.p95, + verificationLanes.awarenessUpdatesVerificationMs.p95, + verificationLanes.reconnectVerificationMs.p95, + verificationLanes.largeDocSyncVerificationMs.p95 + ), yjs_correctness_failures: 0, } @@ -369,6 +449,10 @@ const result = { reconnectConverges: true, }, lanes, + phaseLanes: { + verification: verificationLanes, + work: workLanes, + }, metrics, thresholdPolicy: { mode: 'calibration-only', diff --git a/scripts/proof/yjs-collaboration-soak.mjs b/scripts/proof/yjs-collaboration-soak.mjs index 624c65ac2f..5db2dc5e86 100644 --- a/scripts/proof/yjs-collaboration-soak.mjs +++ b/scripts/proof/yjs-collaboration-soak.mjs @@ -38,6 +38,7 @@ const HAS_EXTERNAL_URL = Boolean( ) const SHOULD_START_SERVER = process.env.SOAK_START_SERVER !== '0' && !HAS_EXTERNAL_URL +const SHOULD_FAIL_ON_ISSUES = process.env.SOAK_FAIL_ON_ISSUES === '1' const PEERS = ['a', 'b', 'c', 'd'] const ACTIONS = [ @@ -91,6 +92,14 @@ let server let lastAction = null let lastReportAt = Date.now() +function elapsedMs() { + return Date.now() - startedAt +} + +function shouldContinue() { + return elapsedMs() < DURATION_MS +} + function write(event) { fs.appendFileSync( LOG_PATH, @@ -426,6 +435,23 @@ async function runScenario(name, fn) { } } +async function runScenarioIfTimeRemaining(name, fn) { + if (!shouldContinue()) { + write({ + type: 'scenario-skip-timebox', + durationMs: DURATION_MS, + elapsedMs: elapsedMs(), + name, + }) + + return false + } + + await runScenario(name, fn) + + return true +} + async function scenarioBaselineSplit(name) { await navigate(name) await click('b', 'split-node', name) @@ -564,7 +590,6 @@ async function scenarioAwareness(name) { } function writeSummary(final = false) { - const elapsedMs = Date.now() - startedAt const sortedIssues = [...issues.values()].sort((a, b) => { const order = { error: 0, suspect: 1, warning: 2 } return (order[a.severity] ?? 9) - (order[b.severity] ?? 9) @@ -576,7 +601,7 @@ function writeSummary(final = false) { `- status: ${final ? 'complete' : 'running'}`, `- url: ${TARGET_URL}`, `- run_id: ${RUN_ID}`, - `- elapsed_ms: ${elapsedMs}`, + `- elapsed_ms: ${elapsedMs()}`, `- actions: ${metrics.actions}`, `- iterations: ${metrics.iterations}`, `- hard_resets: ${metrics.hardResets}`, @@ -584,6 +609,7 @@ function writeSummary(final = false) { `- console_errors: ${metrics.consoleErrors}`, `- page_errors: ${metrics.pageErrors}`, `- issues: ${sortedIssues.length}`, + `- fail_on_issues: ${SHOULD_FAIL_ON_ISSUES}`, `- log: ${LOG_PATH}`, '', '## Scenario Counts', @@ -627,6 +653,7 @@ async function main() { OUTPUT_ROOT, SHOULD_LAUNCH_BROWSER, SHOULD_START_SERVER, + SHOULD_FAIL_ON_ISSUES, SUMMARY_PATH, TARGET_URL, }, @@ -660,24 +687,29 @@ async function main() { await navigate('initial') let seed = Number(process.env.SOAK_START_SEED ?? 1) - while (Date.now() - startedAt < DURATION_MS) { - await runScenario('baseline-split', scenarioBaselineSplit) - await runScenario( - 'offline-undo-remote-split', - scenarioOfflineUndoRemoteSplit - ) - await runScenario( - 'offline-undo-remote-split-redo', - scenarioOfflineUndoRemoteSplitRedo - ) - await runScenario('split-merge-loop', scenarioSplitMergeLoop) - await runScenario('awareness', scenarioAwareness) - await runScenario(`offline-structural-mix-${seed}`, (name) => - scenarioOfflineStructuralMix(name, seed) - ) - await runScenario(`random-control-${seed}`, (name) => - scenarioRandomControl(name, seed) - ) + while (shouldContinue()) { + const currentSeed = seed + const scenarioQueue = [ + ['baseline-split', scenarioBaselineSplit], + ['offline-undo-remote-split', scenarioOfflineUndoRemoteSplit], + ['offline-undo-remote-split-redo', scenarioOfflineUndoRemoteSplitRedo], + ['split-merge-loop', scenarioSplitMergeLoop], + ['awareness', scenarioAwareness], + [ + `offline-structural-mix-${currentSeed}`, + (name) => scenarioOfflineStructuralMix(name, currentSeed), + ], + [ + `random-control-${currentSeed}`, + (name) => scenarioRandomControl(name, currentSeed), + ], + ] + + for (const [name, fn] of scenarioQueue) { + if (!(await runScenarioIfTimeRemaining(name, fn))) { + break + } + } seed += 1 @@ -685,7 +717,7 @@ async function main() { writeSummary(false) lastReportAt = Date.now() console.log( - `[progress] elapsed=${Math.round((Date.now() - startedAt) / 1000)}s actions=${metrics.actions} iterations=${metrics.iterations} issues=${issues.size} summary=${SUMMARY_PATH}` + `[progress] elapsed=${Math.round(elapsedMs() / 1000)}s actions=${metrics.actions} iterations=${metrics.iterations} issues=${issues.size} summary=${SUMMARY_PATH}` ) } } @@ -694,6 +726,8 @@ async function main() { write({ type: 'complete', metrics, issues: [...issues.values()] }) console.log(`[complete] summary=${SUMMARY_PATH}`) console.log(`[complete] log=${LOG_PATH}`) + + return issues.size } async function cleanup() { @@ -707,9 +741,9 @@ async function cleanup() { } main() - .then(async () => { + .then(async (issueCount) => { await cleanup() - process.exit(0) + process.exit(SHOULD_FAIL_ON_ISSUES && issueCount > 0 ? 1 : 0) }) .catch(async (error) => { recordIssue( From ddd2e615128a42ffd744f4c0f7f3cbbf1690dc6b Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Sun, 14 Jun 2026 17:02:09 +0800 Subject: [PATCH 10/11] fix(yjs): harden collaboration edge cases --- packages/slate-yjs/src/core/document.ts | 131 ++++++--- packages/slate-yjs/src/core/editor-adapter.ts | 32 ++- packages/slate-yjs/src/core/operations.ts | 86 +++--- .../src/core/split-history-adapter.ts | 19 +- packages/slate-yjs/src/core/split-history.ts | 12 + .../src/core/undo-manager-adapter.ts | 6 +- packages/slate-yjs/src/react/index.ts | 14 +- .../test/attributes-contract.spec.ts | 54 +++- .../slate-yjs/test/awareness-contract.spec.ts | 49 ++++ .../slate-yjs/test/history-contract.spec.ts | 25 ++ .../test/json-equality-contract.spec.ts | 40 +++ .../operation-exhaustiveness-contract.spec.ts | 268 +++++++++++++++++- .../slate-yjs/test/provider-contract.spec.ts | 60 ++++ .../slate-yjs/test/react-contract.spec.tsx | 88 ++++++ .../slate-yjs/test/selection-contract.spec.ts | 30 ++ .../test/split-node-contract.spec.ts | 25 ++ .../slate-yjs/test/support/collaboration.ts | 7 +- .../undo-manager-adapter-contract.spec.ts | 46 +++ .../core/current/yjs-collaboration.mjs | 129 ++++++--- site/examples/ts/yjs-collaboration.tsx | 7 +- 20 files changed, 980 insertions(+), 148 deletions(-) create mode 100644 packages/slate-yjs/test/json-equality-contract.spec.ts diff --git a/packages/slate-yjs/src/core/document.ts b/packages/slate-yjs/src/core/document.ts index 734a175582..3cb1dcdcec 100644 --- a/packages/slate-yjs/src/core/document.ts +++ b/packages/slate-yjs/src/core/document.ts @@ -538,6 +538,13 @@ export const getYjsVisibleChildren = ( export type YjsVisibleChildrenReader = (node: Y.XmlElement) => YjsNode[] +export type YjsTextPoint = { + readonly childIndex: number + readonly offset: number + readonly parent: Y.XmlElement + readonly text: Y.XmlText +} + const getYjsVisibleChildrenWithResolver = ( root: Y.XmlElement, node: Y.XmlElement, @@ -570,6 +577,44 @@ export const createYjsVisibleChildrenReader = ( getYjsVisibleChildrenWithResolver(root, node, resolveNodeById) } +export const resolveYjsTextPoint = ( + root: Y.XmlElement, + path: Path, + offset: number, + readVisibleChildren: YjsVisibleChildrenReader +): YjsTextPoint | null => { + const target = getYjsNode(root, path) + + if (!(target instanceof Y.XmlText)) { + throw new Error('text operation target is not a Y.XmlText.') + } + + const { index, parent } = getYjsParent(root, path) + const children = readVisibleChildren(parent) + let remainingOffset = offset + + let childIndex = index + + while (childIndex < children.length) { + const child = children[childIndex] + + if (!(child instanceof Y.XmlText)) { + break + } + + const length = getYjsLength(child) + + if (remainingOffset <= length) { + return { childIndex, offset: remainingOffset, parent, text: child } + } + + remainingOffset -= length + childIndex++ + } + + return null +} + export const getYjsVisiblePath = ( root: Y.XmlElement, target: YjsNode @@ -798,37 +843,6 @@ const yjsAttributeRecordsEqual = ( return true } -const getUniformTextAttributes = (node: Y.XmlText): YjsAttributeRecord => { - const delta = node.toDelta() - let attributes: YjsAttributeRecord | undefined - let index = 0 - - while (index < delta.length) { - const part = delta[index] - - if (!isNonEmptyYjsTextDeltaPart(part)) { - index++ - continue - } - - const partAttributes = getPublicAttributes(part.attributes) - - if (attributes === undefined) { - attributes = partAttributes - index++ - continue - } - - if (!yjsAttributeRecordsEqual(attributes, partAttributes)) { - return {} - } - - index++ - } - - return attributes ?? {} -} - const getPublicAttributes = ( attributes?: Readonly ): YjsAttributeRecord => { @@ -852,6 +866,46 @@ const getPublicAttributes = ( return publicAttributes } +const readYjsTextForSlate = ( + node: Y.XmlText +): { readonly attributes: YjsAttributeRecord; readonly text: string } => { + if (getYjsLength(node) === 0) { + return { attributes: {}, text: '' } + } + + const delta = node.toDelta() + let attributes: YjsAttributeRecord | undefined + let attributesAreUniform = true + let text = '' + let index = 0 + + while (index < delta.length) { + const part = delta[index] + + if (part === undefined) { + index++ + continue + } + + text += getYjsTextDeltaPartText(part) + + if (attributesAreUniform && isNonEmptyYjsTextDeltaPart(part)) { + const partAttributes = getPublicAttributes(part.attributes) + + if (attributes === undefined) { + attributes = partAttributes + } else if (!yjsAttributeRecordsEqual(attributes, partAttributes)) { + attributes = {} + attributesAreUniform = false + } + } + + index++ + } + + return { attributes: attributes ?? {}, text } +} + const getPublicYjsAttributes = (node: YjsNode): YjsAttributeRecord => getPublicAttributes(getYjsAttributes(node)) @@ -872,11 +926,12 @@ const readSlateNodeFromYjs = ( ): Descendant => { if (node instanceof Y.XmlText) { const attributes = getPublicYjsAttributes(node) + const readback = readYjsTextForSlate(node) const slateText: YjsAttributeRecord = {} copyRecordAttributes(slateText, attributes) - copyRecordAttributes(slateText, getUniformTextAttributes(node)) - slateText.text = getYjsTextContent(node) + copyRecordAttributes(slateText, readback.attributes) + slateText.text = readback.text return slateText as Descendant } @@ -936,7 +991,10 @@ const cloneYjsNodeWithRoot = ( const clone = new Y.XmlText() setYjsAttributes(clone, attributes) - clone.applyDelta(node.toDelta(), { sanitize: false }) + + if (getYjsLength(node) > 0) { + clone.applyDelta(node.toDelta(), { sanitize: false }) + } return clone } @@ -1491,9 +1549,12 @@ const matchesSlateNodeContent = ( } if ('text' in slateNode) { + const text = String(slateNode.text) + return ( yjsNode instanceof Y.XmlText && - getYjsTextContent(yjsNode) === String(slateNode.text) + getYjsLength(yjsNode) === text.length && + getYjsTextContent(yjsNode) === text ) } diff --git a/packages/slate-yjs/src/core/editor-adapter.ts b/packages/slate-yjs/src/core/editor-adapter.ts index 43063903ec..e90716d724 100644 --- a/packages/slate-yjs/src/core/editor-adapter.ts +++ b/packages/slate-yjs/src/core/editor-adapter.ts @@ -23,6 +23,11 @@ const remoteImportOptions = { tag: ['collaboration', 'remote-yjs-import'], } as const +const remoteNormalizedImportOptions = { + ...remoteImportOptions, + skipNormalize: true, +} as const + const SELECTION_ROOT_TYPE = 'slate-yjs-selection-root' const copyReadonlyArray = (items: readonly T[]): T[] => { @@ -52,8 +57,10 @@ const sanitizeImportSelection = ( return null } + // Selection validation is read-only; avoid a second shallow copy of large + // remote imports before the actual replace payload is copied. const root: Element = { - children: copyReadonlyArray(children), + children: children as Element['children'], type: SELECTION_ROOT_TYPE, } @@ -63,6 +70,10 @@ const sanitizeImportSelection = ( : null } +const canSkipRemoteImportNormalize = ( + children: readonly Descendant[] +): boolean => children.every((child) => NodeApi.isElement(child)) + const isValidImportSelectionPoint = ( root: Element, point: Range['anchor'] @@ -126,13 +137,18 @@ export const createYjsEditorAdapter = (editor: Editor): YjsEditorAdapter => { importing = true try { - editor.update((tx) => { - tx.value.replace({ - children: copyReadonlyArray(children), - marks: null, - selection: nextSelection, - }) - }, remoteImportOptions) + editor.update( + (tx) => { + tx.value.replace({ + children: copyReadonlyArray(children), + marks: null, + selection: nextSelection, + }) + }, + canSkipRemoteImportNormalize(children) + ? remoteNormalizedImportOptions + : remoteImportOptions + ) } finally { importing = false } diff --git a/packages/slate-yjs/src/core/operations.ts b/packages/slate-yjs/src/core/operations.ts index b0a39fcfcc..b614b2a967 100644 --- a/packages/slate-yjs/src/core/operations.ts +++ b/packages/slate-yjs/src/core/operations.ts @@ -27,6 +27,7 @@ import { removeYjsChild, removeYjsVirtualPlaceholderChild, replaceYjsChildren, + resolveYjsTextPoint, setVirtualYjsMove, setVirtualYjsUnwrapMove, splitVisibleYjsChildren, @@ -86,50 +87,6 @@ const getYjsTextForInsert = ( return materializeEmptyYjsText(root, path) } -type YjsTextPoint = { - readonly childIndex: number - readonly offset: number - readonly parent: Y.XmlElement -} - -const resolveYjsTextPoint = ( - root: Y.XmlElement, - path: Path, - offset: number, - readVisibleChildren: YjsVisibleChildrenReader -): YjsTextPoint | null => { - const target = getYjsNode(root, path) - - if (!(target instanceof Y.XmlText)) { - throw new Error('remove_text target is not a Y.XmlText.') - } - - const { index, parent } = getYjsParent(root, path) - const children = readVisibleChildren(parent) - let remainingOffset = offset - - let childIndex = index - - while (childIndex < children.length) { - const child = children[childIndex] - - if (!(child instanceof Y.XmlText)) { - break - } - - const length = getYjsLength(child) - - if (remainingOffset <= length) { - return { childIndex, offset: remainingOffset, parent } - } - - remainingOffset -= length - childIndex++ - } - - return null -} - const deleteYjsTextRange = ( root: Y.XmlElement, path: Path, @@ -329,7 +286,18 @@ export const applySlateOperationToYjs = ( throw new Error('insert_text target is not a Y.XmlText.') } - text.insert(operation.offset, operation.text) + const point = resolveYjsTextPoint( + root, + operation.path, + operation.offset, + getReadVisibleChildren() + ) + + if (point === null) { + return operationTrace(operation) + } + + point.text.insert(point.offset, operation.text) return operationTrace(operation) } @@ -369,16 +337,36 @@ export const applySlateOperationToYjs = ( const { index, parent } = getYjsParent(root, operation.path) if (target instanceof Y.XmlText) { - const rightText = getYjsTextContentFrom(target, operation.position) + const readVisibleChildren = getReadVisibleChildren() + const point = resolveYjsTextPoint( + root, + operation.path, + operation.position, + readVisibleChildren + ) + + if (point === null) { + return operationTrace(operation) + } + + const children = readVisibleChildren(point.parent) + const nextChild = children[point.childIndex + 1] + const textLength = getYjsLength(point.text) + + if (point.offset === textLength && nextChild instanceof Y.XmlText) { + return operationTrace(operation) + } + + const rightText = getYjsTextContentFrom(point.text, point.offset) if (rightText.length > 0) { - target.delete(operation.position, rightText.length) + point.text.delete(point.offset, rightText.length) } insertYjsChild( root, - parent, - index + 1, + point.parent, + point.childIndex + 1, createYjsText(rightText, toYjsAttributeRecord(operation.properties)) ) diff --git a/packages/slate-yjs/src/core/split-history-adapter.ts b/packages/slate-yjs/src/core/split-history-adapter.ts index 6aeca53d95..7e44fef54e 100644 --- a/packages/slate-yjs/src/core/split-history-adapter.ts +++ b/packages/slate-yjs/src/core/split-history-adapter.ts @@ -3,12 +3,14 @@ import * as Y from 'yjs' import { toYjsAttributeRecord } from './attributes' import { + createYjsVisibleChildrenReader, getYjsLength, getYjsNode, getYjsNodeIf, getYjsParent, getYjsTextContentFrom, removeYjsChild, + resolveYjsTextPoint, SPLIT_UNDO_TEXT_ATTRIBUTE, yjsTextContentEndsWith, } from './document' @@ -74,6 +76,21 @@ const completeSplitHistory = ( textProperties: pendingTextSplitHistory.textProperties, }) +const readSplitRightText = ( + root: Y.XmlElement, + path: SplitNodeOperation['path'], + position: SplitNodeOperation['position'] +): string => { + const point = resolveYjsTextPoint( + root, + path, + position, + createYjsVisibleChildrenReader(root) + ) + + return point === null ? '' : getYjsTextContentFrom(point.text, point.offset) +} + const peekSplit = ( item: YjsUndoManagerStackItem | null ): { @@ -159,7 +176,7 @@ export const createYjsSplitHistoryAdapter = ({ const pending: PendingTextSplitHistory = { elementPath, - rightText: getYjsTextContentFrom(text, textSplit.position), + rightText: readSplitRightText(root, textSplit.path, textSplit.position), textPath: textSplit.path, textProperties: toYjsAttributeRecord(textSplit.properties), } diff --git a/packages/slate-yjs/src/core/split-history.ts b/packages/slate-yjs/src/core/split-history.ts index 911229b9e4..e87f55735e 100644 --- a/packages/slate-yjs/src/core/split-history.ts +++ b/packages/slate-yjs/src/core/split-history.ts @@ -126,6 +126,10 @@ const appendTextContent = ( source: Y.XmlText, extraAttributes: YjsAttributeRecord = {} ): string => { + if (getYjsLength(source) === 0) { + return '' + } + let offset = getYjsLength(target) let insertedText = '' const extraAttributesHaveKeys = hasAttributes(extraAttributes) @@ -237,6 +241,10 @@ const findLastVisibleText = ( export const getTrailingSplitUndoText = ( text: Y.XmlText ): TrailingSplitUndoText | null => { + if (getYjsLength(text) === 0) { + return null + } + const delta = text.toDelta() let offset = getYjsLength(text) let value = '' @@ -289,6 +297,10 @@ const visibleTextStartsWithReader = ( } if (current instanceof Y.XmlText) { + if (getYjsLength(current) === 0) { + return true + } + const currentDelta = current.toDelta() let deltaIndex = 0 diff --git a/packages/slate-yjs/src/core/undo-manager-adapter.ts b/packages/slate-yjs/src/core/undo-manager-adapter.ts index 6fd79fed80..be398936cf 100644 --- a/packages/slate-yjs/src/core/undo-manager-adapter.ts +++ b/packages/slate-yjs/src/core/undo-manager-adapter.ts @@ -72,11 +72,13 @@ const popExpectedStackItem = ( item: YjsUndoManagerStackItem, message: string ): void => { - const popped = stack.pop() + const lastIndex = stack.length - 1 - if (popped !== item) { + if (lastIndex < 0 || stack[lastIndex] !== item) { throw new Error(message) } + + stack.pop() } export const createYjsUndoManagerAdapter = ( diff --git a/packages/slate-yjs/src/react/index.ts b/packages/slate-yjs/src/react/index.ts index 538a5fec81..462093a0d6 100644 --- a/packages/slate-yjs/src/react/index.ts +++ b/packages/slate-yjs/src/react/index.ts @@ -304,7 +304,19 @@ const overlayDataEqual = (a: unknown, b: unknown): boolean => { } keyCount++ if (key === 'cursor') { - if (!remoteCursorsEqual(a.cursor, b.cursor)) { + if (isRemoteCursorLike(a.cursor) || isRemoteCursorLike(b.cursor)) { + if (!remoteCursorsEqual(a.cursor, b.cursor)) { + return false + } + continue + } + if (isRecord(a.cursor) && isRecord(b.cursor)) { + if (!shallowEqual(a.cursor, b.cursor)) { + return false + } + continue + } + if (!Object.is(a.cursor, b.cursor)) { return false } continue diff --git a/packages/slate-yjs/test/attributes-contract.spec.ts b/packages/slate-yjs/test/attributes-contract.spec.ts index f19d4e9609..47fddbfa31 100644 --- a/packages/slate-yjs/test/attributes-contract.spec.ts +++ b/packages/slate-yjs/test/attributes-contract.spec.ts @@ -14,6 +14,15 @@ import { setYjsNodeAttributes, } from '../src/core/replacement' +const internalYjsAttributeKeys = [ + 'slate:yjs-hidden', + 'slate:yjs-id', + 'slate:type', + 'slate:yjs-split-undo-text', + 'slate:yjs-virtual-child-id', + 'slate:yjs-virtual-placeholder', +] as const + describe('@slate/yjs attribute contract', () => { it('writes non-string Yjs attributes through the interop boundary', () => { const doc = new Y.Doc() @@ -55,6 +64,40 @@ describe('@slate/yjs attribute contract', () => { ]) }) + it('preserves attributes on empty Yjs text nodes', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const paragraph = new Y.XmlElement('paragraph') + const text = new Y.XmlText() + + setSlateYjsAttribute(paragraph, 'type', 'paragraph') + setYjsAttribute(text, 'bold', true) + root.insert(0, [paragraph]) + paragraph.insert(0, [text]) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { + children: [{ bold: true, text: '' }], + type: 'paragraph', + }, + ]) + }) + + it('preserves null-valued text attributes through readback', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const paragraph = new Y.XmlElement('paragraph') + const [text] = createYjsNodes([{ color: null, text: 'alpha' }]) + + setSlateYjsAttribute(paragraph, 'type', 'paragraph') + root.insert(0, [paragraph]) + paragraph.insert(0, [text]) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { children: [{ color: null, text: 'alpha' }], type: 'paragraph' }, + ]) + }) + it('does not rewrite semantically unchanged object attributes', () => { const doc = new Y.Doc() const root = doc.get('slate', Y.XmlElement) @@ -77,15 +120,20 @@ describe('@slate/yjs attribute contract', () => { }) it('rejects Slate-authored attributes reserved for internal Yjs state', () => { - for (const key of ['slate:yjs-hidden', 'slate:type']) { - const node = { + for (const key of internalYjsAttributeKeys) { + const element = { children: [{ text: 'alpha' }], [key]: true, type: 'paragraph', } as unknown as Descendant + const text = { [key]: true, text: 'alpha' } as unknown as Descendant assert.throws( - () => createYjsNodes([node]), + () => createYjsNodes([element]), + new RegExp(`Cannot set internal Yjs attribute "${key}"`) + ) + assert.throws( + () => createYjsNodes([text]), new RegExp(`Cannot set internal Yjs attribute "${key}"`) ) } diff --git a/packages/slate-yjs/test/awareness-contract.spec.ts b/packages/slate-yjs/test/awareness-contract.spec.ts index 82eadacaab..e2c48fd9c0 100644 --- a/packages/slate-yjs/test/awareness-contract.spec.ts +++ b/packages/slate-yjs/test/awareness-contract.spec.ts @@ -13,6 +13,7 @@ import { getYjsTrace, type Peer, paragraph, + readEditorYjsState, runYjsUpdate, subscribeYjsAwareness, } from './support/collaboration' @@ -144,6 +145,25 @@ describe('@slate/yjs awareness contract', () => { assert.equal(getYjsRemoteCursors(peer).length, 1) }) + it('gates single remote cursor reads by connection and local client id', () => { + const { awareness, peer } = createAwarePeer() + const range = selection([1, 0], 3) + const yjs = readEditorYjsState(peer.editor) + + sendRemoteSelection(peer, awareness, range) + + assert.deepEqual(yjs.remoteCursor(101), { + clientId: 101, + data: { name: 'Ada' }, + selection: range, + }) + assert.equal(yjs.remoteCursor(2), null) + + disconnectYjsPeer(peer) + + assert.equal(yjs.remoteCursor(101), null) + }) + it('increments awareness revision on remote changes', () => { const { awareness, peer } = createAwarePeer() const before = getYjsAwarenessRevision(peer) @@ -188,6 +208,35 @@ describe('@slate/yjs awareness contract', () => { unsubscribe() }) + it('does not notify awareness subscribers for equivalent nested cursor payloads', () => { + const { peer } = createAwarePeer() + const range = selection() + let notifications = 0 + const unsubscribe = subscribeYjsAwareness(peer, () => { + notifications += 1 + }) + + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range, { + name: 'Ada', + palette: ['tomato', 'white'], + profile: { role: 'reviewer', accent: undefined }, + }) + }) + notifications = 0 + runYjsUpdate(peer, (yjs) => { + yjs.sendSelection(range, { + name: 'Ada', + palette: ['tomato', 'white'], + profile: { role: 'reviewer' }, + }) + }) + + assert.equal(notifications, 0) + + unsubscribe() + }) + it('rebases remote selections through virtual moved-node identity', () => { const { awareness, peer } = createAwarePeer() diff --git a/packages/slate-yjs/test/history-contract.spec.ts b/packages/slate-yjs/test/history-contract.spec.ts index cea1a3db89..5df7dd249f 100644 --- a/packages/slate-yjs/test/history-contract.spec.ts +++ b/packages/slate-yjs/test/history-contract.spec.ts @@ -68,4 +68,29 @@ describe('@slate/yjs history contract', () => { assert.deepEqual(history.undos, []) }) + + it('removes rejected operation suffixes from redo history', () => { + const keepOperation: Operation = { + offset: 0, + path: [0, 0], + text: 'a', + type: 'insert_text', + } + const rejectedOperation: Operation = { + offset: 1, + path: [0, 0], + text: '!', + type: 'insert_text', + } + const history = { + redos: [{ operations: [keepOperation, rejectedOperation] }], + undos: [], + } + + removeRejectedYjsOperationsFromHistory(createHistoryEditor(history), [ + rejectedOperation, + ]) + + assert.deepEqual(history.redos, [{ operations: [keepOperation] }]) + }) }) diff --git a/packages/slate-yjs/test/json-equality-contract.spec.ts b/packages/slate-yjs/test/json-equality-contract.spec.ts new file mode 100644 index 0000000000..7c56c2ad1e --- /dev/null +++ b/packages/slate-yjs/test/json-equality-contract.spec.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { areJsonLikeValuesEqual } from '../src/core/json-equality' + +describe('@slate/yjs JSON-like equality contract', () => { + it('treats object key order and undefined fields as insignificant', () => { + assert.equal( + areJsonLikeValuesEqual( + { + cursor: { + name: 'Ada', + palette: ['tomato', 'white'], + accent: undefined, + }, + role: 'reviewer', + }, + { + role: 'reviewer', + cursor: { + palette: ['tomato', 'white'], + name: 'Ada', + }, + } + ), + true + ) + }) + + it('treats array order and defined object keys as significant', () => { + assert.equal( + areJsonLikeValuesEqual(['tomato', 'white'], ['white', 'tomato']), + false + ) + assert.equal( + areJsonLikeValuesEqual({ name: 'Ada' }, { name: 'Ada', role: null }), + false + ) + }) +}) diff --git a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts index e63feb7bfa..020e1233ca 100644 --- a/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts +++ b/packages/slate-yjs/test/operation-exhaustiveness-contract.spec.ts @@ -2,7 +2,13 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Operation } from 'slate' import * as Y from 'yjs' - +import { + createYjsNodes, + createYjsVisibleChildrenReader, + getYjsTextContentFrom, + readSlateValueFromYjs, + resolveYjsTextPoint, +} from '../src/core/document' import { applySlateOperationToYjs, isNoopSlateOperationForYjs, @@ -38,6 +44,20 @@ describe('@slate/yjs operation encoder exhaustiveness contract', () => { assert.equal(isNoopSlateOperationForYjs(operation), true) }) + it('treats replace_children with equivalent object attributes as a no-op', () => { + const operation: Operation = { + children: [{ role: 'note', text: 'alpha' }], + index: 0, + newChildren: [{ text: 'alpha', role: 'note' }], + newSelection: null, + path: [0], + selection: null, + type: 'replace_children', + } + + assert.equal(isNoopSlateOperationForYjs(operation), true) + }) + it('treats selection operations as document-content no-ops', () => { const doc = new Y.Doc() const root = doc.get('slate', Y.XmlElement) @@ -53,6 +73,252 @@ describe('@slate/yjs operation encoder exhaustiveness contract', () => { assert.equal(applySlateOperationToYjs(root, operation), null) }) + it('routes insert_text offsets across adjacent Yjs text containers', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]), + ]) + + applySlateOperationToYjs(root, { + offset: 'alphabe'.length, + path: [0, 0], + text: '!', + type: 'insert_text', + }) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { + children: [{ text: 'alpha' }, { text: 'be!ta' }], + type: 'paragraph', + }, + ]) + }) + + it('routes remove_text ranges across adjacent Yjs text containers', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]), + ]) + + applySlateOperationToYjs(root, { + offset: 'alph'.length, + path: [0, 0], + text: 'abe', + type: 'remove_text', + }) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { + children: [{ text: 'alph' }, { text: 'ta' }], + type: 'paragraph', + }, + ]) + }) + + it('routes split_node offsets across adjacent Yjs text containers', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]), + ]) + + applySlateOperationToYjs(root, { + path: [0, 0], + position: 'alphabe'.length, + properties: {}, + type: 'split_node', + }) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { + children: [{ text: 'alpha' }, { text: 'be' }, { text: 'ta' }], + type: 'paragraph', + }, + ]) + }) + + it('does not materialize empty text when split_node lands on an adjacent Yjs text boundary', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]), + ]) + + applySlateOperationToYjs(root, { + path: [0, 0], + position: 'alpha'.length, + properties: {}, + type: 'split_node', + }) + + assert.deepEqual(readSlateValueFromYjs(root), [ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]) + }) + + it('resolves shared text points across adjacent Yjs text containers', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]), + ]) + + const point = resolveYjsTextPoint( + root, + [0, 0], + 'alphabe'.length, + createYjsVisibleChildrenReader(root) + ) + + assert.notEqual(point, null) + assert.equal(point?.offset, 2) + assert.equal( + point === null ? '' : getYjsTextContentFrom(point.text, point.offset), + 'ta' + ) + }) + + it('returns null for shared text points beyond adjacent Yjs text containers', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }, { text: 'beta' }], + type: 'paragraph', + }, + ]), + ]) + + assert.equal( + resolveYjsTextPoint( + root, + [0, 0], + 'alphabeta!'.length, + createYjsVisibleChildrenReader(root) + ), + null + ) + }) + + it('elides move_node operations when the source path is stale', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }], + type: 'paragraph', + }, + ]), + ]) + + assert.deepEqual( + applySlateOperationToYjs(root, { + newPath: [0], + path: [9], + type: 'move_node', + }), + { + fallback: 'missing-move-source-elided', + mode: 'traceable-fallback', + operationType: 'move_node', + } + ) + }) + + it('elides move_node operations when the destination path is stale', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }], + type: 'paragraph', + }, + ]), + ]) + + assert.deepEqual( + applySlateOperationToYjs(root, { + newPath: [9, 0], + path: [0], + type: 'move_node', + }), + { + fallback: 'missing-move-destination-elided', + mode: 'traceable-fallback', + operationType: 'move_node', + } + ) + }) + + it('elides merge_node operations when the right text target is already absent', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + + root.insert(0, [ + ...createYjsNodes([ + { + children: [{ text: 'alpha' }], + type: 'paragraph', + }, + ]), + ]) + + assert.deepEqual( + applySlateOperationToYjs(root, { + path: [0, 1], + position: 'alpha'.length, + properties: {}, + type: 'merge_node', + }), + { + fallback: 'empty-text-merge-elided', + mode: 'traceable-fallback', + operationType: 'merge_node', + } + ) + }) + it('rejects a future Slate operation instead of silently skipping it', () => { const doc = new Y.Doc() const root = doc.get('slate', Y.XmlElement) diff --git a/packages/slate-yjs/test/provider-contract.spec.ts b/packages/slate-yjs/test/provider-contract.spec.ts index db81de3548..2dea49e380 100644 --- a/packages/slate-yjs/test/provider-contract.spec.ts +++ b/packages/slate-yjs/test/provider-contract.spec.ts @@ -93,6 +93,20 @@ class AsyncDisconnectProvider extends FakeProvider { } } +class AsyncRejectDisconnectProvider extends FakeProvider { + rejectDisconnect: (() => void) | null = null + + override disconnect(): Promise { + this.calls.push('disconnect') + + return new Promise((_resolve, reject) => { + this.rejectDisconnect = () => { + reject(new Error('disconnect failed')) + } + }) + } +} + class StatusOnlyProvider extends FakeProvider { override connect(): void { this.calls.push('connect') @@ -334,6 +348,33 @@ describe('@slate/yjs provider contract', () => { cleanup() }) + it('does not notify provider subscribers for unchanged status or sync events', () => { + const provider = new FakeProvider() + const { cleanup, editor } = createProviderEditor(provider) + const yjs = readEditorYjsState(editor) + const seen: [YjsProviderStatus | null, boolean | null][] = [] + const unsubscribe = yjs.subscribeProvider(() => { + seen.push([yjs.providerStatus(), yjs.providerSynced()]) + }) + + provider.emitStatus('disconnected') + provider.emitSync(false) + provider.emitSynced(false) + provider.emitStatus('connected') + provider.emitStatus('connected') + provider.emitSynced(true) + provider.emitSync(true) + + unsubscribe() + + assert.deepEqual(seen, [ + ['connected', false], + ['connected', true], + ]) + + cleanup() + }) + it('does not seed a provider-owned document before provider sync', () => { const provider = new FakeProvider() const { cleanup, editor } = createProviderEditor(provider) @@ -824,6 +865,25 @@ describe('@slate/yjs provider contract', () => { cleanup() }) + it('does not reconnect when async provider disconnect rejects', async () => { + const provider = new AsyncRejectDisconnectProvider() + const { cleanup, editor } = createProviderEditor(provider) + + runEditorYjsUpdate(editor, (yjs) => { + yjs.reconnect() + }) + + assert.deepEqual(provider.calls, ['disconnect']) + + provider.rejectDisconnect?.() + await Promise.resolve() + + assert.deepEqual(provider.calls, ['disconnect']) + assert.equal(readEditorYjsState(editor).connected(), false) + + cleanup() + }) + it('keeps pause separate from provider disconnect', () => { const provider = new FakeProvider() const { cleanup, editor } = createProviderEditor(provider) diff --git a/packages/slate-yjs/test/react-contract.spec.tsx b/packages/slate-yjs/test/react-contract.spec.tsx index d579151a40..e2ce6d47ec 100644 --- a/packages/slate-yjs/test/react-contract.spec.tsx +++ b/packages/slate-yjs/test/react-contract.spec.tsx @@ -378,6 +378,46 @@ describe('@slate/yjs react contract', () => { peer.cleanup() }) + it('refreshes overlay positions when a remote cursor selection changes', () => { + const awareness = new FakeAwareness(10) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'j', + numericClientId: 10, + }) + + setEditorDomApi(peer.editor, { resolveRangeRect: () => null }) + + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { + const [positions] = useYjsRemoteCursorOverlayPositions(editor) + const anchor = positions[0]?.range.anchor + + return ( + + {anchor?.path.join('.') ?? 'none'}:{anchor?.offset} + + ) + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([0, 0], 1)) + }) + + assert.equal(view.container.textContent, '0.0:1') + + act(() => { + sendRemoteSelection(peer, awareness, selection([2, 0], 2)) + }) + + assert.equal(view.container.textContent, '2.0:2') + + view.unmount() + peer.cleanup() + }) + it('refreshes remote cursor overlay data when overlay deps change', () => { const awareness = new FakeAwareness(5) const peer = createYjsPeer({ @@ -473,4 +513,52 @@ describe('@slate/yjs react contract', () => { view.unmount() peer.cleanup() }) + + it('keeps custom cursor-named overlay data stable across unrelated editor updates', () => { + const awareness = new FakeAwareness(11) + const peer = createYjsPeer({ + awareness, + children: initialValue(), + clientId: 'k', + numericClientId: 11, + }) + let renders = 0 + + setEditorDomApi(peer.editor, { resolveRangeRect: () => null }) + + const OverlayProbe = ({ editor }: EditorProbeProps): React.ReactElement => { + const [positions] = useYjsRemoteCursorOverlayPositions< + { color: string; name: string }, + { cursor: { clientId: number; label: string } } + >(editor, { + data: (cursor) => ({ + cursor: { clientId: cursor.clientId, label: cursor.data.name }, + }), + }) + + renders += 1 + + return {positions[0]?.data.cursor.label} + } + + const view = render() + + act(() => { + sendRemoteSelection(peer, awareness, selection([1, 0], 1)) + }) + + const rendersAfterRemoteSelection = renders + + act(() => { + peer.editor.update((tx) => { + tx.text.insert('!', { at: { path: [2, 0], offset: 'gamma'.length } }) + }) + }) + + assert.equal(view.container.textContent, 'Ada') + assert.equal(renders, rendersAfterRemoteSelection) + + view.unmount() + peer.cleanup() + }) }) diff --git a/packages/slate-yjs/test/selection-contract.spec.ts b/packages/slate-yjs/test/selection-contract.spec.ts index 94bcf46178..f4e0333025 100644 --- a/packages/slate-yjs/test/selection-contract.spec.ts +++ b/packages/slate-yjs/test/selection-contract.spec.ts @@ -118,6 +118,36 @@ describe('@slate/yjs selection relative-position contract', () => { ) }) + it('keeps adjacent text boundary positions distinct', () => { + const peer = createYjsPeer({ + children: [ + { + children: [{ text: 'alpha' }, { bold: true, text: 'beta' }], + type: 'paragraph', + }, + ], + clientId: 'b', + numericClientId: clientIds.b, + }) + const endOfLeft = { path: [0, 0], offset: 'alpha'.length } + const startOfRight = { path: [0, 1], offset: 0 } + + assert.deepEqual( + yjsRelativePositionToSlatePoint( + getYjsRoot(peer), + slatePointToYjsRelativePosition(getYjsRoot(peer), endOfLeft) + ), + endOfLeft + ) + assert.deepEqual( + yjsRelativePositionToSlatePoint( + getYjsRoot(peer), + slatePointToYjsRelativePosition(getYjsRoot(peer), startOfRight) + ), + startOfRight + ) + }) + it('rebases a stored point across a concurrent text insert', () => { const peers = createPeers(['a', 'b', 'c']) const [a, b] = peers diff --git a/packages/slate-yjs/test/split-node-contract.spec.ts b/packages/slate-yjs/test/split-node-contract.spec.ts index 2b37f17430..6be014db3b 100644 --- a/packages/slate-yjs/test/split-node-contract.spec.ts +++ b/packages/slate-yjs/test/split-node-contract.spec.ts @@ -22,6 +22,7 @@ import { getYjsTrace, type Peer, paragraph, + readPeerChildren, redoYjsPeer, redoYjsPeerAndSync, syncConnectedPeers, @@ -145,6 +146,30 @@ describe('@slate/yjs split_node collaboration contract', () => { ]) }) + it('splits a block at a text leaf boundary without materializing empty text', () => { + const peer = createPeer('b', undefined, [ + { + children: [{ text: 'alpha' }, { bold: true, text: 'beta' }], + type: 'paragraph', + }, + ]) + + peer.editor.update((tx) => { + tx.nodes.split({ at: { path: [0, 0], offset: 'alpha'.length } }) + }) + + assert.deepEqual(readPeerChildren(peer), [ + { + children: [{ text: 'alpha' }], + type: 'paragraph', + }, + { + children: [{ bold: true, text: 'beta' }], + type: 'paragraph', + }, + ]) + }) + it('splits virtual moved content by visible child position', () => { const peer = createPeer('b', undefined, [ { type: 'quote', children: [] }, diff --git a/packages/slate-yjs/test/support/collaboration.ts b/packages/slate-yjs/test/support/collaboration.ts index 8aa56175d3..cd83181bc8 100644 --- a/packages/slate-yjs/test/support/collaboration.ts +++ b/packages/slate-yjs/test/support/collaboration.ts @@ -329,13 +329,16 @@ export const syncConnectedPeers = (peers: readonly Peer[]): void => { continue } - const update = Y.encodeStateAsUpdate(source.doc) - for (const target of peers) { if (source === target || !isYjsPeerConnected(target)) { continue } + const update = Y.encodeStateAsUpdate( + source.doc, + Y.encodeStateVector(target.doc) + ) + Y.applyUpdate(target.doc, update, source) } } diff --git a/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts b/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts index 5ad4c98d5b..7188a4cf07 100644 --- a/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts +++ b/packages/slate-yjs/test/undo-manager-adapter-contract.spec.ts @@ -40,6 +40,52 @@ describe('@slate/yjs UndoManager adapter contract', () => { doc.destroy() }) + it('rejects moving non-top private Yjs stack items', () => { + const doc = new Y.Doc() + const root = doc.get('slate', Y.XmlElement) + const origin = {} + const undoManager = new Y.UndoManager(root, { + trackedOrigins: new Set([origin]), + }) + const adapter = createYjsUndoManagerAdapter(undoManager) + + doc.transact(() => { + root.insert(0, [new Y.XmlText()]) + }, origin) + undoManager.stopCapturing() + const firstUndoItem = adapter.peekUndo() + + doc.transact(() => { + root.insert(1, [new Y.XmlText()]) + }, origin) + undoManager.stopCapturing() + const secondUndoItem = adapter.peekUndo() + + assert.ok(firstUndoItem) + assert.ok(secondUndoItem) + assert.throws( + () => adapter.moveUndoToRedo(firstUndoItem), + /Cannot move a non-top undo item/ + ) + + adapter.moveUndoToRedo(secondUndoItem) + adapter.moveUndoToRedo(firstUndoItem) + + assert.throws( + () => adapter.moveRedoToUndo(secondUndoItem), + /Cannot move a non-top redo item/ + ) + assert.equal(adapter.peekRedo(), firstUndoItem) + assert.equal(adapter.redoDepth(), 2) + + adapter.moveRedoToUndo(firstUndoItem) + adapter.moveRedoToUndo(secondUndoItem) + assert.equal(adapter.peekUndo(), secondUndoItem) + + undoManager.destroy() + doc.destroy() + }) + it('pins Yjs private stack usage to one adapter file and a fixed version', () => { const controllerSource = readFileSync( new URL('../src/core/controller.ts', import.meta.url), diff --git a/scripts/benchmarks/core/current/yjs-collaboration.mjs b/scripts/benchmarks/core/current/yjs-collaboration.mjs index d938e76f91..27dd935993 100644 --- a/scripts/benchmarks/core/current/yjs-collaboration.mjs +++ b/scripts/benchmarks/core/current/yjs-collaboration.mjs @@ -178,34 +178,58 @@ const runYjsUpdate = (peer, fn) => { }) } -const getParagraphTexts = (peer) => - Editor.getSnapshot(peer.editor).children.map((_, index) => - Editor.string(peer.editor, [index]) - ) +const getParagraphTexts = (peer) => { + const children = Editor.getSnapshot(peer.editor).children + const texts = new Array(children.length) + let index = 0 + + while (index < children.length) { + texts[index] = Editor.string(peer.editor, [index]) + index++ + } -const syncConnectedPeers = (peers) => { - for (const source of peers) { - if (!getYjsState(source).connected()) { + return texts +} + +const encodePeerUpdateForTarget = (source, target) => + Y.encodeStateAsUpdate(source.doc, Y.encodeStateVector(target.doc)) + +const syncPeerToConnectedPeers = (source, peers) => { + if (!getYjsState(source).connected()) { + return + } + + for (const target of peers) { + if (source === target || !getYjsState(target).connected()) { continue } - const update = Y.encodeStateAsUpdate(source.doc) + const update = encodePeerUpdateForTarget(source, target) - for (const target of peers) { - if (source === target || !getYjsState(target).connected()) { - continue - } + Y.applyUpdate(target.doc, update, source) + } +} - Y.applyUpdate(target.doc, update, source) - } +const syncConnectedPeers = (peers) => { + for (const source of peers) { + syncPeerToConnectedPeers(source, peers) } } const assertPeerTexts = (peers) => { - const expected = getParagraphTexts(peers[0]) + const firstPeer = peers[0] - for (const peer of peers) { + assert(firstPeer) + + const expected = getParagraphTexts(firstPeer) + let index = 1 + + while (index < peers.length) { + const peer = peers[index] + + assert(peer) assert.deepEqual(getParagraphTexts(peer), expected) + index++ } } @@ -218,14 +242,21 @@ const assertNoRootSnapshot = (peer) => { ) } -const measurePhased = ({ verify, work }) => { +const assertPeersNoRootSnapshot = (peers) => { + for (const peer of peers) { + assertNoRootSnapshot(peer) + } +} + +const measurePhased = ({ setup, verify, work }) => { const totalSamples = [] const verificationSamples = [] const workSamples = [] for (let iteration = 0; iteration < iterations + 1; iteration += 1) { + const setupContext = setup?.() const workStart = performance.now() - const context = work() + const context = work(setupContext) const workDuration = performance.now() - workStart const verificationStart = performance.now() @@ -259,26 +290,31 @@ const insertDistributedText = (peer, ops, blocks, textPrefix) => { const measureMultiEditorSync = () => measurePhased({ + setup: () => ({ + peers: createSeededPeers({ blocks: syncBlocks, prefix: 'sync' }), + }), verify: ({ peers }) => { assertPeerTexts(peers) - assertNoRootSnapshot(peers[0]) + assertPeersNoRootSnapshot(peers) }, - work: () => { - const peers = createSeededPeers({ blocks: syncBlocks, prefix: 'sync' }) - + work: ({ peers }) => { insertDistributedText(peers[0], syncOps, syncBlocks, 's') - syncConnectedPeers(peers) + syncPeerToConnectedPeers(peers[0], peers) return { peers } }, }) -const broadcastAwareness = (source, targets) => { +const broadcastAwareness = (source, peers) => { const state = source.awareness.getLocalState() assert(state) - for (const target of targets) { + for (const target of peers) { + if (target === source) { + continue + } + target.awareness.setRemoteState(source.doc.clientID, state) } } @@ -290,12 +326,7 @@ const selection = (blockIndex, offset = 1) => ({ const measureAwarenessUpdates = () => measurePhased({ - verify: ({ peers }) => { - for (const peer of peers) { - assert.equal(getYjsState(peer).remoteCursors().length, peerCount - 1) - } - }, - work: () => { + setup: () => { const blocks = Math.max(1, Math.min(syncBlocks, awarenessUpdates)) const peers = createSeededPeers({ blocks, @@ -303,9 +334,16 @@ const measureAwarenessUpdates = () => withAwareness: true, }) + return { blocks, peers } + }, + verify: ({ peers }) => { + for (const peer of peers) { + assert.equal(getYjsState(peer).remoteCursors().length, peerCount - 1) + } + }, + work: ({ blocks, peers }) => { for (let index = 0; index < awarenessUpdates; index += 1) { const source = peers[index % peers.length] - const targets = peers.filter((peer) => peer !== source) runYjsUpdate(source, (yjs) => { yjs.sendSelection(selection(index % blocks), { @@ -313,7 +351,7 @@ const measureAwarenessUpdates = () => update: index, }) }) - broadcastAwareness(source, targets) + broadcastAwareness(source, peers) } return { peers } @@ -322,15 +360,17 @@ const measureAwarenessUpdates = () => const measureReconnect = () => measurePhased({ + setup: () => ({ + peers: createSeededPeers({ + blocks: syncBlocks, + prefix: 'reconnect', + }), + }), verify: ({ offline, peers }) => { assertPeerTexts(peers) - assertNoRootSnapshot(offline) + assertPeersNoRootSnapshot(peers) }, - work: () => { - const peers = createSeededPeers({ - blocks: syncBlocks, - prefix: 'reconnect', - }) + work: ({ peers }) => { const [online, offline] = peers runYjsUpdate(offline, (yjs) => yjs.disconnect()) @@ -347,15 +387,16 @@ const measureReconnect = () => const measureLargeDocSync = () => measurePhased({ + setup: () => ({ + peers: createSeededPeers({ blocks: largeBlocks, prefix: 'large' }), + }), verify: ({ peers }) => { assertPeerTexts(peers) - assertNoRootSnapshot(peers[0]) + assertPeersNoRootSnapshot(peers) }, - work: () => { - const peers = createSeededPeers({ blocks: largeBlocks, prefix: 'large' }) - + work: ({ peers }) => { insertDistributedText(peers[0], largeOps, largeBlocks, 'l') - syncConnectedPeers(peers) + syncPeerToConnectedPeers(peers[0], peers) return { peers } }, diff --git a/site/examples/ts/yjs-collaboration.tsx b/site/examples/ts/yjs-collaboration.tsx index 75f57aca61..a15f1935ed 100644 --- a/site/examples/ts/yjs-collaboration.tsx +++ b/site/examples/ts/yjs-collaboration.tsx @@ -308,13 +308,16 @@ const createExampleNetwork = (): ExampleNetwork => { continue } - const update = Y.encodeStateAsUpdate(source.doc) - for (const target of peers) { if (source === target || !target.connected) { continue } + const update = Y.encodeStateAsUpdate( + source.doc, + Y.encodeStateVector(target.doc) + ) + Y.applyUpdate(target.doc, update, source.doc) } } From 9f43f8516aaccb37b897b5e4a991efe6aa0298f8 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Mon, 15 Jun 2026 10:18:17 +0800 Subject: [PATCH 11/11] fix(yjs): harden collaboration proof gates Record remote import trace metadata, split Yjs collaboration benchmark phases, and keep restored soak runners manual-only by removing package script entry points and stale release-proof soak claims. --- package.json | 6 +- packages/slate-browser/src/core/index.ts | 2 - .../slate-browser/src/core/release-proof.ts | 58 ------- .../test/core/release-proof.test.ts | 34 +--- .../slate-browser/test/core/scenario.test.ts | 2 +- packages/slate-yjs/src/core/controller.ts | 6 +- packages/slate-yjs/src/core/types.ts | 4 + .../test/package-config-contract.spec.ts | 159 +++++++++++++++++- .../test/remote-import-contract.spec.ts | 143 ++++++++++++++++ .../core/current/yjs-collaboration.mjs | 91 ++++++++-- 10 files changed, 392 insertions(+), 113 deletions(-) create mode 100644 packages/slate-yjs/test/remote-import-contract.spec.ts diff --git a/package.json b/package.json index 959c53f2ac..d693bbd8c6 100644 --- a/package.json +++ b/package.json @@ -78,12 +78,8 @@ "test:integration-local": "playwright install && playwright test playwright/integration", "test:mobile-device-proof": "bun ./scripts/proof/mobile-device-proof.mjs", "test:mobile-device-proof:raw": "SLATE_BROWSER_RAW_MOBILE_REQUIRED=1 bun ./scripts/proof/mobile-device-proof.mjs", - "test:persistent-soak": "bun build:next && bun ./scripts/proof/persistent-browser-soak.mjs", - "test:yjs-hocuspocus-production-soak": "bun ./scripts/proof/yjs-hocuspocus-production-soak.mjs", - "test:yjs-hocuspocus-persistent-room-soak": "bun ./scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs", - "test:yjs-collaboration-soak": "bun ./scripts/proof/yjs-collaboration-soak.mjs", "test:release-discipline": "bun test ./packages/slate/test/public-surface-contract.ts ./packages/slate/test/public-field-hard-cut-contract.ts ./packages/slate/test/escape-hatch-inventory-contract.ts ./packages/slate/test/write-boundary-contract.ts ./packages/slate/test/leaf-lifecycle-contract.ts ./packages/slate/test/selection-rebase-contract.ts ./packages/slate/test/compat-alias-hard-cut-contract.ts ./packages/slate/test/editor-foundation-contract.ts ./packages/slate/test/core-benchmark-scripts-contract.ts ./packages/slate/test/release-scripts-contract.ts ./packages/slate-react/test/rendered-dom-shape-contract.tsx --bail 1", - "test:release-proof": "bun test:release-discipline && bun --filter slate-browser test:proof && bun test:mobile-device-proof && bun test:persistent-soak", + "test:release-proof": "bun test:release-discipline && bun --filter slate-browser test:proof && bun test:mobile-device-proof", "test:slate-browser": "bun --filter slate-browser test", "test:slate-browser:core": "bun --filter slate-browser test:core", "test:slate-browser:dom": "bun --filter slate-browser test:dom", diff --git a/packages/slate-browser/src/core/index.ts b/packages/slate-browser/src/core/index.ts index 383069002f..9972fc96bc 100644 --- a/packages/slate-browser/src/core/index.ts +++ b/packages/slate-browser/src/core/index.ts @@ -36,11 +36,9 @@ export { export { assertSlateBrowserReleaseProof, createBrowserMobileReleaseProofArtifact, - createPersistentBrowserSoakProofArtifact, createReleaseDisciplineProofArtifact, SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS, type SlateBrowserMobileDeviceProofArtifact, - type SlateBrowserPersistentSoakProofArtifact, type SlateBrowserReleaseClaim, type SlateBrowserReleaseDisciplineProofArtifact, type SlateBrowserReleaseProofArtifact, diff --git a/packages/slate-browser/src/core/release-proof.ts b/packages/slate-browser/src/core/release-proof.ts index a4cfdcfbcd..227bb28355 100644 --- a/packages/slate-browser/src/core/release-proof.ts +++ b/packages/slate-browser/src/core/release-proof.ts @@ -13,7 +13,6 @@ export type SlateBrowserReleaseClaim = | 'ios-safari-device-browser-text-input' | 'ios-safari-device-browser-ime-commit' | 'native-mobile-clipboard' - | 'persistent-browser-caret-soak' | 'release-discipline-guards' export type SlateBrowserMobileReleaseCapability = @@ -31,16 +30,6 @@ export type SlateBrowserMobileDeviceProofArtifact = { transport: BrowserMobileTransportId } -export type SlateBrowserPersistentSoakProofArtifact = { - browserName: string - iterations: number - kind: 'persistent-browser-soak' - passed: boolean - profilePersistence: 'ephemeral' | 'persistent' - replayable: boolean - scenario: string -} - export type SlateBrowserReleaseDisciplineProofArtifact = { guards: string[] kind: 'release-discipline' @@ -49,14 +38,12 @@ export type SlateBrowserReleaseDisciplineProofArtifact = { export type SlateBrowserReleaseProofArtifact = | SlateBrowserMobileDeviceProofArtifact - | SlateBrowserPersistentSoakProofArtifact | SlateBrowserReleaseDisciplineProofArtifact export type SlateBrowserReleaseProofOptions = { artifacts: readonly SlateBrowserReleaseProofArtifact[] claims: readonly SlateBrowserReleaseClaim[] requiredDisciplineGuards?: readonly string[] - requiredSoakIterations?: number } export type SlateBrowserReleaseProofResult = { @@ -100,26 +87,6 @@ export const createBrowserMobileReleaseProofArtifact = ({ } } -export const createPersistentBrowserSoakProofArtifact = ({ - browserName, - iterations, - passed, - profilePersistence, - replayable, - scenario, -}: Omit< - SlateBrowserPersistentSoakProofArtifact, - 'kind' ->): SlateBrowserPersistentSoakProofArtifact => ({ - browserName, - iterations, - kind: 'persistent-browser-soak', - passed, - profilePersistence, - replayable, - scenario, -}) - export const createReleaseDisciplineProofArtifact = ({ guards, passed, @@ -173,27 +140,6 @@ const validateMobileClaim = ( } } -const validatePersistentSoak = ( - issues: string[], - artifacts: readonly SlateBrowserReleaseProofArtifact[], - requiredSoakIterations: number -) => { - const artifact = artifacts.find( - (candidate) => - candidate.kind === 'persistent-browser-soak' && - candidate.passed && - candidate.profilePersistence === 'persistent' && - candidate.replayable && - candidate.iterations >= requiredSoakIterations - ) - - if (!artifact) { - issues.push( - `Missing persistent browser soak proof with at least ${requiredSoakIterations} replayable iterations` - ) - } -} - const validateReleaseDiscipline = ( issues: string[], artifacts: readonly SlateBrowserReleaseProofArtifact[], @@ -234,7 +180,6 @@ export const validateSlateBrowserReleaseProof = ({ artifacts, claims, requiredDisciplineGuards = SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS, - requiredSoakIterations = 5, }: SlateBrowserReleaseProofOptions): SlateBrowserReleaseProofResult => { const issues: string[] = [] @@ -286,9 +231,6 @@ export const validateSlateBrowserReleaseProof = ({ 'native-mobile-clipboard' ) break - case 'persistent-browser-caret-soak': - validatePersistentSoak(issues, artifacts, requiredSoakIterations) - break case 'release-discipline-guards': validateReleaseDiscipline(issues, artifacts, requiredDisciplineGuards) break diff --git a/packages/slate-browser/test/core/release-proof.test.ts b/packages/slate-browser/test/core/release-proof.test.ts index e1a52d7caf..3286ceaf5d 100644 --- a/packages/slate-browser/test/core/release-proof.test.ts +++ b/packages/slate-browser/test/core/release-proof.test.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from 'bun:test' import { assertSlateBrowserReleaseProof, createBrowserMobileReleaseProofArtifact, - createPersistentBrowserSoakProofArtifact, createReleaseDisciplineProofArtifact, SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS, type SlateBrowserMobileDeviceProofArtifact, @@ -11,7 +10,7 @@ import { } from '../../src/core' describe('release proof helpers', () => { - test('accepts direct Appium mobile proof and persistent browser soak artifacts', () => { + test('accepts direct Appium mobile proof and release discipline artifacts', () => { const artifacts = [ createBrowserMobileReleaseProofArtifact({ passed: true, @@ -23,14 +22,6 @@ describe('release proof helpers', () => { scenario: 'inline-edge-ime', transport: 'appium-ios', }), - createPersistentBrowserSoakProofArtifact({ - browserName: 'chromium', - iterations: 5, - passed: true, - profilePersistence: 'persistent', - replayable: true, - scenario: 'richtext-warm-toolbar-mark-arrow-conformance', - }), createReleaseDisciplineProofArtifact({ guards: [...SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS], passed: true, @@ -45,7 +36,6 @@ describe('release proof helpers', () => { 'android-chrome-device-browser-ime-commit', 'ios-safari-device-browser-text-input', 'ios-safari-device-browser-ime-commit', - 'persistent-browser-caret-soak', 'release-discipline-guards', ], }) @@ -120,28 +110,6 @@ describe('release proof helpers', () => { ]) }) - test('requires persistent profile replay for browser soak claims', () => { - const result = validateSlateBrowserReleaseProof({ - artifacts: [ - createPersistentBrowserSoakProofArtifact({ - browserName: 'chromium', - iterations: 50, - passed: true, - profilePersistence: 'ephemeral', - replayable: true, - scenario: 'richtext-warm-toolbar-mark-arrow-conformance', - }), - ], - claims: ['persistent-browser-caret-soak'], - requiredSoakIterations: 10, - }) - - expect(result.ok).toBe(false) - expect(result.issues).toEqual([ - 'Missing persistent browser soak proof with at least 10 replayable iterations', - ]) - }) - test('requires all release discipline guards', () => { const result = validateSlateBrowserReleaseProof({ artifacts: [ diff --git a/packages/slate-browser/test/core/scenario.test.ts b/packages/slate-browser/test/core/scenario.test.ts index 028ce99706..99a3062251 100644 --- a/packages/slate-browser/test/core/scenario.test.ts +++ b/packages/slate-browser/test/core/scenario.test.ts @@ -185,7 +185,7 @@ describe('scenario helpers', () => { 'playwright/stress/generated-editing.test.ts' ) expect(scripts['test:stress']).toContain('PLAYWRIGHT_RETRIES=0') - expect(scripts['test:release-proof']).toContain('test:persistent-soak') + expect(scripts['test:release-proof']).not.toContain('test:persistent-soak') expect(scripts['test:release-proof']).not.toContain( 'test:mobile-device-proof:raw' ) diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts index aa36dbc848..ae13a1431f 100644 --- a/packages/slate-yjs/src/core/controller.ts +++ b/packages/slate-yjs/src/core/controller.ts @@ -534,7 +534,11 @@ export class YjsController { const children = readSlateValueFromYjs(this.root) - this.traceEntries.push({ mode }) + this.traceEntries.push({ + importedChildren: children.length, + importKind: 'full-read-replace', + mode, + }) this.editorAdapter.replaceValue( children, this.awarenessAdapter.currentSelection() diff --git a/packages/slate-yjs/src/core/types.ts b/packages/slate-yjs/src/core/types.ts index 16a168a1a0..82d5aa021c 100644 --- a/packages/slate-yjs/src/core/types.ts +++ b/packages/slate-yjs/src/core/types.ts @@ -116,6 +116,10 @@ export type YjsTraceFallback = export type YjsTraceEntry = { readonly fallback?: YjsTraceFallback + /** Number of top-level Slate children read from Yjs during a full import. */ + readonly importedChildren?: number + /** Describes the import strategy used when Yjs state is read into Slate. */ + readonly importKind?: 'full-read-replace' readonly mode: YjsTraceMode readonly operationType?: string } diff --git a/packages/slate-yjs/test/package-config-contract.spec.ts b/packages/slate-yjs/test/package-config-contract.spec.ts index 8f778194cc..272cb73f5a 100644 --- a/packages/slate-yjs/test/package-config-contract.spec.ts +++ b/packages/slate-yjs/test/package-config-contract.spec.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { describe, it } from 'node:test' import { isRecord } from '../src/core/record' import { SUPPORTED_YJS_UNDO_MANAGER_VERSION } from '../src/core/undo-manager-adapter' @@ -22,6 +22,7 @@ type PackageJson = { readonly exports?: PackageExports readonly optionalDependencies?: DependencyMap readonly peerDependencies?: DependencyMap + readonly scripts?: DependencyMap } type TsConfigJson = { @@ -126,9 +127,14 @@ const readPackageJson = (path: string): PackageJson => { exports: readPackageExports(record), optionalDependencies: readDependencyMap(record, 'optionalDependencies'), peerDependencies: readDependencyMap(record, 'peerDependencies'), + scripts: readDependencyMap(record, 'scripts'), } } +const yjsCollaborationBenchmarkPath = + '../../../scripts/benchmarks/core/current/yjs-collaboration.mjs' +const benchmarkStatsPath = '../../../scripts/benchmarks/shared/stats.mjs' + const readTsConfigJson = (path: string): TsConfigJson => { const record = readJsonRecord(path) const compilerOptions = readOptionalRecord(record, 'compilerOptions') @@ -183,6 +189,157 @@ describe('@slate/yjs package config contract', () => { } }) + it('keeps restored long-running Yjs soak scripts manual-only', () => { + const rootPackage = readPackageJson('../../../package.json') + const scripts = rootPackage.scripts ?? {} + const manualSoakScripts = [ + '../../../scripts/proof/yjs-collaboration-soak.mjs', + '../../../scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs', + '../../../scripts/proof/persistent-browser-soak.mjs', + '../../../scripts/proof/yjs-hocuspocus-production-soak.mjs', + ] + + assert.equal(scripts['test:yjs-collaboration-soak'], undefined) + assert.equal(scripts['test:yjs-hocuspocus-persistent-room-soak'], undefined) + assert.equal(scripts['test:persistent-soak'], undefined) + assert.equal(scripts['test:yjs-hocuspocus-production-soak'], undefined) + + for (const manualSoakScript of manualSoakScripts) { + assert.equal(existsSync(new URL(manualSoakScript, import.meta.url)), true) + } + + for (const script of Object.values(scripts)) { + assert.equal( + script.includes('scripts/proof/yjs-collaboration-soak.mjs'), + false + ) + assert.equal( + script.includes( + 'scripts/proof/yjs-hocuspocus-persistent-room-soak.mjs' + ), + false + ) + assert.equal( + script.includes('scripts/proof/persistent-browser-soak.mjs'), + false + ) + assert.equal( + script.includes('scripts/proof/yjs-hocuspocus-production-soak.mjs'), + false + ) + } + }) + + it('keeps fast checks free of long-running proof gates', () => { + const rootPackage = readPackageJson('../../../package.json') + const scripts = rootPackage.scripts ?? {} + const fastScriptNames = [ + 'check', + 'lint', + 'typecheck', + 'test', + 'test:bun', + 'test:vitest', + ] + const forbiddenFastCheckFragments = [ + 'test:integration', + 'test:integration-local', + 'test:release-proof', + 'test:persistent-soak', + 'test:mobile-device-proof', + 'test:yjs-collaboration-soak', + 'test:yjs-hocuspocus-persistent-room-soak', + 'scripts/proof/', + 'playwright test playwright/integration', + ] + + for (const scriptName of fastScriptNames) { + const script = scripts[scriptName] + + assert.equal(typeof script, 'string', `${scriptName} script must exist.`) + + for (const fragment of forbiddenFastCheckFragments) { + assert.equal( + script.includes(fragment), + false, + `${scriptName} must not include ${fragment}.` + ) + } + } + + assert.match(scripts['check:full'] ?? '', /\btest:release-proof\b/) + assert.match(scripts['check:full'] ?? '', /\btest:integration-local\b/) + }) + + it('keeps Yjs collaboration benchmark phase metrics explicit', () => { + const rootPackage = readPackageJson('../../../package.json') + const scripts = rootPackage.scripts ?? {} + const benchmarkSource = readFileSync( + new URL(yjsCollaborationBenchmarkPath, import.meta.url), + 'utf8' + ) + const requiredMetricNames = [ + 'yjs_collaboration_worst_p95_ms', + 'yjs_collaboration_worst_work_p95_ms', + 'yjs_collaboration_worst_verification_p95_ms', + 'yjs_large_doc_local_edit_p95_ms', + 'yjs_large_doc_remote_apply_p95_ms', + 'yjs_large_doc_remote_encode_p95_ms', + 'yjs_large_doc_remote_sync_p95_ms', + 'yjs_correctness_failures', + ] + + assert.equal( + scripts['bench:core:yjs-collaboration:local'], + 'bun ./scripts/benchmarks/core/current/yjs-collaboration.mjs' + ) + + for (const metricName of requiredMetricNames) { + assert.match(benchmarkSource, new RegExp(`\\b${metricName}:`)) + } + + assert.match(benchmarkSource, /phaseLanes:\s*\{/) + assert.match( + benchmarkSource, + /for \(const \[name, value\] of Object\.entries\(metrics\)\)/ + ) + assert.match(benchmarkSource, /METRIC \$\{name\}=\$\{value\}/) + }) + + it('keeps Yjs benchmark artifacts diagnostic enough for perf decisions', () => { + const benchmarkSource = readFileSync( + new URL(yjsCollaborationBenchmarkPath, import.meta.url), + 'utf8' + ) + const statsSource = readFileSync( + new URL(benchmarkStatsPath, import.meta.url), + 'utf8' + ) + const requiredSummaryFields = [ + 'samples', + 'mean', + 'median', + 'p75', + 'p95', + 'p99', + 'min', + 'max', + ] + + for (const field of requiredSummaryFields) { + assert.match(statsSource, new RegExp(`\\b${field}:`)) + } + + assert.match(benchmarkSource, /artifactVersion:\s*1/) + assert.match(benchmarkSource, /thresholdPolicy:\s*\{/) + assert.match(benchmarkSource, /releaseGate:\s*false/) + assert.match(benchmarkSource, /repeatRunsRequiredBeforeEnforcement:\s*3/) + assert.match( + benchmarkSource, + /tmp\/slate-yjs-collaboration-benchmark\.json/ + ) + }) + it('keeps package exports aligned with built entrypoints', () => { const yjsPackage = readPackageJson('../package.json') diff --git a/packages/slate-yjs/test/remote-import-contract.spec.ts b/packages/slate-yjs/test/remote-import-contract.spec.ts new file mode 100644 index 0000000000..1fe5c49dd1 --- /dev/null +++ b/packages/slate-yjs/test/remote-import-contract.spec.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + type Descendant, + defineEditorExtension, + type Operation, + type Editor as SlateEditor, +} from 'slate' + +import { + clearYjsTrace, + createSeededYjsPeers, + getPeerTopLevelTexts, + getYjsTrace, + paragraph, + syncConnectedPeers, +} from './support/collaboration' + +type RecordedRemoteImportCommit = { + readonly operationTypes: Operation['type'][] + readonly tags: readonly string[] +} + +const largeValue = (count = 32): Descendant[] => + Array.from({ length: count }, (_, index) => + paragraph(`block-${String(index).padStart(3, '0')}`) + ) + +const recordRemoteImportCommits = ( + editor: SlateEditor +): RecordedRemoteImportCommit[] => { + const commits: RecordedRemoteImportCommit[] = [] + + editor.extend( + defineEditorExtension({ + name: 'remote-import-commit-recorder', + setup() { + return { + onCommit({ commit }): void { + if (!commit.tags.includes('remote-yjs-import')) { + return + } + + commits.push({ + operationTypes: commit.operations.map( + (operation) => operation.type + ), + tags: [...commit.tags], + }) + }, + } + }, + }) + ) + + return commits +} + +describe('@slate/yjs remote import contract', () => { + it('imports remote Yjs updates through one full replace commit today', () => { + const [source, target] = createSeededYjsPeers({ + children: largeValue(), + clientIds: ['source', 'target'], + numericClientIds: { source: 101, target: 202 }, + }) + + assert(source) + assert(target) + + const remoteImportCommits = recordRemoteImportCommits(target.editor) + + clearYjsTrace(target) + source.editor.update((tx) => { + tx.text.insert('!', { + at: { path: [0, 0], offset: 'block-000'.length }, + }) + }) + syncConnectedPeers([source, target]) + + assert.equal(getPeerTopLevelTexts(target)[0], 'block-000!') + assert.deepEqual(remoteImportCommits, [ + { + operationTypes: [], + tags: ['collaboration', 'remote-yjs-import'], + }, + ]) + assert.deepEqual(getYjsTrace(target), [ + { + importedChildren: 32, + importKind: 'full-read-replace', + mode: 'remote-reconcile', + }, + ]) + }) + + it('converges a large remote document after distributed text edits', () => { + const blockCount = 256 + const middleIndex = Math.floor(blockCount / 2) + const [source, target] = createSeededYjsPeers({ + children: largeValue(blockCount), + clientIds: ['source', 'target'], + numericClientIds: { source: 101, target: 202 }, + }) + + assert(source) + assert(target) + + clearYjsTrace(target) + source.editor.update((tx) => { + tx.text.insert('!', { + at: { path: [0, 0], offset: 'block-000'.length }, + }) + tx.text.insert('?', { + at: { + path: [middleIndex, 0], + offset: `block-${String(middleIndex).padStart(3, '0')}`.length, + }, + }) + tx.text.insert('.', { + at: { + path: [blockCount - 1, 0], + offset: `block-${String(blockCount - 1).padStart(3, '0')}`.length, + }, + }) + }) + syncConnectedPeers([source, target]) + + const targetTexts = getPeerTopLevelTexts(target) + + assert.equal(targetTexts.length, blockCount) + assert.equal(targetTexts[0], 'block-000!') + assert.equal(targetTexts[middleIndex], `block-${middleIndex}?`) + assert.equal(targetTexts[blockCount - 1], `block-${blockCount - 1}.`) + assert.deepEqual(getPeerTopLevelTexts(source), targetTexts) + assert.deepEqual(getYjsTrace(target), [ + { + importedChildren: blockCount, + importKind: 'full-read-replace', + mode: 'remote-reconcile', + }, + ]) + }) +}) diff --git a/scripts/benchmarks/core/current/yjs-collaboration.mjs b/scripts/benchmarks/core/current/yjs-collaboration.mjs index 27dd935993..d457466fcb 100644 --- a/scripts/benchmarks/core/current/yjs-collaboration.mjs +++ b/scripts/benchmarks/core/current/yjs-collaboration.mjs @@ -210,6 +210,33 @@ const syncPeerToConnectedPeers = (source, peers) => { } } +const syncPeerToConnectedPeersWithTiming = (source, peers) => { + let applyDuration = 0 + let encodeDuration = 0 + + if (!getYjsState(source).connected()) { + return { applyDuration, encodeDuration } + } + + for (const target of peers) { + if (source === target || !getYjsState(target).connected()) { + continue + } + + const encodeStart = performance.now() + const update = encodePeerUpdateForTarget(source, target) + + encodeDuration += performance.now() - encodeStart + + const applyStart = performance.now() + + Y.applyUpdate(target.doc, update, source) + applyDuration += performance.now() - applyStart + } + + return { applyDuration, encodeDuration } +} + const syncConnectedPeers = (peers) => { for (const source of peers) { syncPeerToConnectedPeers(source, peers) @@ -386,21 +413,53 @@ const measureReconnect = () => }) const measureLargeDocSync = () => - measurePhased({ - setup: () => ({ - peers: createSeededPeers({ blocks: largeBlocks, prefix: 'large' }), - }), - verify: ({ peers }) => { + (() => { + const localEditSamples = [] + const remoteApplySamples = [] + const remoteEncodeSamples = [] + const remoteSyncSamples = [] + const totalSamples = [] + const verificationSamples = [] + const workSamples = [] + + for (let iteration = 0; iteration < iterations + 1; iteration += 1) { + const peers = createSeededPeers({ blocks: largeBlocks, prefix: 'large' }) + + const localEditStart = performance.now() + insertDistributedText(peers[0], largeOps, largeBlocks, 'l') + const localEditDuration = performance.now() - localEditStart + + const remoteSyncStart = performance.now() + const remoteTiming = syncPeerToConnectedPeersWithTiming(peers[0], peers) + const remoteSyncDuration = performance.now() - remoteSyncStart + const workDuration = localEditDuration + remoteSyncDuration + + const verificationStart = performance.now() assertPeerTexts(peers) assertPeersNoRootSnapshot(peers) - }, - work: ({ peers }) => { - insertDistributedText(peers[0], largeOps, largeBlocks, 'l') - syncPeerToConnectedPeers(peers[0], peers) + const verificationDuration = performance.now() - verificationStart + + if (iteration > 0) { + localEditSamples.push(localEditDuration) + remoteApplySamples.push(remoteTiming.applyDuration) + remoteEncodeSamples.push(remoteTiming.encodeDuration) + remoteSyncSamples.push(remoteSyncDuration) + workSamples.push(workDuration) + verificationSamples.push(verificationDuration) + totalSamples.push(workDuration + verificationDuration) + } + } - return { peers } - }, - }) + return { + localEdit: summarize(localEditSamples), + remoteApply: summarize(remoteApplySamples), + remoteEncode: summarize(remoteEncodeSamples), + remoteSync: summarize(remoteSyncSamples), + total: summarize(totalSamples), + verification: summarize(verificationSamples), + work: summarize(workSamples), + } + })() const measuredLanes = { multiEditorSync: measureMultiEditorSync(), @@ -421,6 +480,10 @@ const workLanes = { awarenessUpdatesWorkMs: measuredLanes.awarenessUpdates.work, reconnectWorkMs: measuredLanes.reconnect.work, largeDocSyncWorkMs: measuredLanes.largeDocSync.work, + largeDocLocalEditMs: measuredLanes.largeDocSync.localEdit, + largeDocRemoteApplyMs: measuredLanes.largeDocSync.remoteApply, + largeDocRemoteEncodeMs: measuredLanes.largeDocSync.remoteEncode, + largeDocRemoteSyncMs: measuredLanes.largeDocSync.remoteSync, } const verificationLanes = { @@ -445,6 +508,10 @@ const metrics = { verificationLanes.reconnectVerificationMs.p95, yjs_large_doc_sync_p95_ms: lanes.largeDocSyncMs.p95, yjs_large_doc_sync_work_p95_ms: workLanes.largeDocSyncWorkMs.p95, + yjs_large_doc_local_edit_p95_ms: workLanes.largeDocLocalEditMs.p95, + yjs_large_doc_remote_apply_p95_ms: workLanes.largeDocRemoteApplyMs.p95, + yjs_large_doc_remote_encode_p95_ms: workLanes.largeDocRemoteEncodeMs.p95, + yjs_large_doc_remote_sync_p95_ms: workLanes.largeDocRemoteSyncMs.p95, yjs_large_doc_sync_verification_p95_ms: verificationLanes.largeDocSyncVerificationMs.p95, yjs_collaboration_worst_p95_ms: Math.max(