diff --git a/package-lock.json b/package-lock.json index 3914caa2..91b29ba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "version": "14.0.0-rc.20", "license": "MIT", "dependencies": { - "lib0": "^1.0.0-rc.15" + "lib0": "file:../lib0" }, "devDependencies": { "@types/node": "^22.14.1", @@ -18,7 +18,7 @@ "@y/y": ".", "dpdm": "^4.0.1", "markdownlint": "^0.40.0", - "markdownlint-cli": "^0.43.0", + "markdownlint-cli": "^0.49.0", "standard": "^17.1.2", "typescript": "^5.9.3" }, @@ -31,6 +31,30 @@ "url": "https://github.com/sponsors/dmonad" } }, + "../lib0": { + "version": "1.0.0-rc.17", + "license": "MIT", + "bin": { + "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "src/bin/gentesthtml.js", + "0serve": "src/bin/0serve.js" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "c8": "^11.0.0", + "dpdm": "^4.0.1", + "jsdom": "^29.1.1", + "standard": "^17.1.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -204,39 +228,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -882,13 +873,13 @@ "license": "MIT" }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/concat-map": { @@ -2075,6 +2066,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2143,23 +2152,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -2512,9 +2504,9 @@ } }, "node_modules/ignore": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz", - "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -3097,22 +3089,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3282,22 +3258,8 @@ } }, "node_modules/lib0": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.15.tgz", - "integrity": "sha512-TYRy/rwOV3xJ9IjTAJeQdoBAKaLKIZQUacAyT5PPRDyi2ejnITaNAbHn06zfdttz/aI3D+wzkgcwJzY7DwFJ4Q==", - "license": "MIT", - "bin": { - "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js", - "0gentesthtml": "src/bin/gentesthtml.js", - "0serve": "src/bin/0serve.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } + "resolved": "../lib0", + "link": true }, "node_modules/linkify-it": { "version": "5.0.1", @@ -3410,15 +3372,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -3452,99 +3424,100 @@ } }, "node_modules/markdownlint-cli": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.43.0.tgz", - "integrity": "sha512-6vwurKK4B21eyYzwgX6ph13cZS7hE6LZfcS8QyD722CyxVD2RtAvbZK2p7k+FZbbKORulEuwl+hJaEq1l6/hoQ==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.49.0.tgz", + "integrity": "sha512-vS5tWq5W91Gg33LD4pyAaXPclnz/sRvo6/RGOyDQjQ3eds2DkK6H4szUuE0M9TiRB/u/VBx1gtd9Ktrtx5WlSA==", "dev": true, "license": "MIT", "dependencies": { - "commander": "~12.1.0", - "glob": "~11.0.0", - "ignore": "~6.0.2", - "js-yaml": "^4.1.0", + "commander": "~15.0.0", + "deep-extend": "~0.6.0", + "ignore": "~7.0.5", + "js-yaml": "~4.2.0", "jsonc-parser": "~3.3.1", - "jsonpointer": "5.0.1", - "markdownlint": "~0.36.1", - "minimatch": "~10.0.1", + "jsonpointer": "~5.0.1", + "markdown-it": "~14.2.0", + "markdownlint": "~0.41.0", + "minimatch": "~10.2.5", "run-con": "~1.3.2", - "smol-toml": "~1.3.1" + "smol-toml": "~1.6.1", + "tinyglobby": "~0.2.17" }, "bin": { "markdownlint": "markdownlint.js" }, "engines": { - "node": ">=18" + "node": ">=22" } }, - "node_modules/markdownlint-cli/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/markdownlint-cli/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/markdownlint-cli/node_modules/markdownlint": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.36.1.tgz", - "integrity": "sha512-s73fU2CQN7WCgjhaQUQ8wYESQNzGRNOKDd+3xgVqu8kuTEhmwepd/mxOv1LR2oV046ONrTLBFsM7IoKWNvmy5g==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.0.tgz", + "integrity": "sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A==", "dev": true, "license": "MIT", "dependencies": { - "markdown-it": "14.1.0", - "markdownlint-micromark": "0.1.12" + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.2.1" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/DavidAnson" } }, - "node_modules/markdownlint-cli/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/markdownlint-micromark": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.12.tgz", - "integrity": "sha512-RlB6EwMGgc0sxcIhOQ2+aq7Zw1V2fBnzbXKGgYK/mVWdT7cz34fteKSwfYeo4rL6+L/q2tyC9QtD/PgZbkdyJQ==", + "node_modules/markdownlint-cli/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/DavidAnson" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/math-intrinsics": { @@ -4432,13 +4405,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4540,6 +4506,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -5163,9 +5142,9 @@ } }, "node_modules/smol-toml": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.4.tgz", - "integrity": "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5483,6 +5462,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/package.json b/package.json index baaab3af..48917579 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ }, "homepage": "https://docs.yjs.dev", "dependencies": { - "lib0": "^1.0.0-rc.15" + "lib0": "file:../lib0" }, "devDependencies": { "@types/node": "^22.14.1", @@ -79,7 +79,7 @@ "@y/y": ".", "dpdm": "^4.0.1", "markdownlint": "^0.40.0", - "markdownlint-cli": "^0.43.0", + "markdownlint-cli": "^0.49.0", "standard": "^17.1.2", "typescript": "^5.9.3" }, diff --git a/src/utils/Doc.js b/src/utils/Doc.js index 27488357..7e2dbcfb 100644 --- a/src/utils/Doc.js +++ b/src/utils/Doc.js @@ -226,6 +226,9 @@ export class Doc extends ObservableV2 { */ destroy () { this.isDestroyed = true + // Tear down the top-level shared types as RDTs (emits their `'destroy'` event so any bindings + // unsubscribe). Only top-level types are destroyed here; nested child types are not recursed. + this.share.forEach(type => type.destroy()) array.from(this.subdocs).forEach(subdoc => subdoc.destroy()) const item = this._item if (item !== null) { diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js index 5b83464d..450390cb 100644 --- a/src/utils/Transaction.js +++ b/src/utils/Transaction.js @@ -236,17 +236,29 @@ const cleanupTransactions = (transactionCleanups, i) => { }) ) fs.push(() => { - // deep observe events + // deep observe events + the RDT `'delta'` channel. `changedParentTypes` holds the changed + // type AND all of its ancestors, so both `observeDeep` and `'delta'` bubble identically. transaction.changedParentTypes.forEach((events, type) => { // We need to think about the possibility that the user transforms the // Y.Doc in the event. - if (type._dEH.l.length > 0 && (type._item === null || !type._item.deleted)) { - /** - * @type {YEvent} - */ - const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null)) + if (type._item !== null && type._item.deleted) return + const hasDeep = type._dEH.l.length > 0 + const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0 + const maintaining = type._delta !== null + if (!hasDeep && !hasDeltaListeners && !maintaining) return + /** + * @type {YEvent} + */ + const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null)) + if (hasDeep) { callEventHandlerListeners(type._dEH, deepEventHandler, transaction) } + if (hasDeltaListeners || maintaining) { + // the type-rooted deep delta of this transaction (a nested `modify` chain for ancestors) + const change = /** @type {any} */ (deepEventHandler.getDelta({ renderer: type._renderer, deep: true }).done()) + type._delta?.apply(change) // keep the cache current (incl. ancestors and diff-renderer attributions) + if (hasDeltaListeners) type.emit('delta', [change]) + } }) }) fs.push(() => doc.emit('afterTransaction', [transaction, doc])) diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 8b404b18..889d36b2 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -86,13 +86,13 @@ export class YEvent { /** * @template {boolean} [Deep=false] * @param {object} [opts] - * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer` + * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to the target type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed * @param {Deep} [opts.deep] * @return {Deep extends true ? Delta : Delta>} The Delta representation of this type. * * @public */ - getDelta ({ renderer = baseRenderer, deep } = {}) { + getDelta ({ renderer = this.target._renderer, deep } = {}) { const itemsToRender = mergeIdSets([diffIdSet(this.transaction.insertSet, this.transaction.deleteSet), diffIdSet(this.transaction.deleteSet, this.transaction.insertSet)]) /** * @todo this should be done only one in the transaction step diff --git a/src/ytype.js b/src/ytype.js index 90dc8146..3b230ea8 100644 --- a/src/ytype.js +++ b/src/ytype.js @@ -10,6 +10,7 @@ import * as math from 'lib0/math' import * as object from 'lib0/object' import * as s from 'lib0/schema' import * as traits from 'lib0/traits' +import { ObservableV2 } from 'lib0/observable' import { Item, ContentAny, @@ -50,11 +51,12 @@ const maxSearchMarker = 80 * @todo SHOULD NOT RETURN AN OBJECT! * @param {Array>?} attrs * @param {boolean} deleted - whether the attributed item is deleted - * @return {Attribution?} + * @return {Attribution|undefined} `undefined` when there is no attribution — under lib0's tri-state + * that means "skip / inherit the builder's attribution context" (NOT `null`, which would clear it). */ export const createAttributionFromAttributionItems = (attrs, deleted) => { if (attrs == null) { - return null + return undefined } /** * @type {Attribution} @@ -631,14 +633,25 @@ export const callTypeObservers = (type, transaction, event) => { } /** - * Abstract Yjs Type class + * Abstract Yjs Type class. + * + * A `YType` is a {@link https://github.com/dmonad/lib0 lib0} `RDT` ("replicated data type", see + * `lib0/delta/rdt.js`): it emits a `'delta'` event whenever its state changes, accepts foreign + * changes via {@link YType#applyDelta}, exposes its delta {@link YType#$delta schema}, and can be + * torn down via {@link YType#destroy}. This lets a `YType` be `bind()`-ed to any other RDT (another + * `YType`, an in-memory delta, a DOM subtree, …). The legacy {@link YType#observe `observe`} / + * {@link YType#observeDeep `observeDeep`} `YEvent` API continues to work alongside the `'delta'` + * channel. + * * @template {delta.DeltaConf} [DConf=any] + * @extends {ObservableV2<{ delta: (delta: delta.Delta) => void, destroy: (type: YType) => void }>} */ -export class YType { +export class YType extends ObservableV2 { /** * @param {delta.DeltaConfGetName?} name */ constructor (name = null) { + super() /** * @type {delta.DeltaConfGetName} */ @@ -675,10 +688,12 @@ export class YType { */ this._searchMarker = null /** - * @type {delta.DeltaBuilder} - * @private + * Maintained deep-delta cache backing {@link YType#delta}. `null` until `delta` is first + * accessed; thereafter kept current on every event of this type (incrementally, by applying the + * deep change) and re-diffed by {@link YType#useRenderer}. Cleared by {@link YType#clearCache}. + * @type {delta.DeltaBuilderAny | null} */ - this._content = /** @type {delta.DeltaBuilderAny} */ (delta.create()) + this._delta = null this._legacyTypeRef = this.name == null ? YXmlFragmentRefID : YXmlElementRefID /** * @type {Array|null} @@ -689,6 +704,105 @@ export class YType { * This flag is updated when a formatting item is integrated (see ContentFormat.integrate) */ this._hasFormatting = false + /** + * The active default renderer. Used by `toDelta`, `applyDelta`, and the events whenever no + * explicit renderer is passed. Change it via {@link YType#useRenderer}. + * @type {AbstractRenderer} + */ + this._renderer = baseRenderer + } + + /** + * Schema of the deltas this type produces — part of the lib0 `RDT` interface. + * + * @type {s.Schema>} + */ + get $delta () { + return /** @type {any} */ (delta.$deltaAny) + } + + /** + * The deep delta of this type (the full nested content tree, children rendered as their own + * deltas). + * + * The returned value is the type's **live** maintained cache: it is materialized on first access + * and then kept current on every event fired on this type (and re-diffed by + * {@link YType#useRenderer}), so a reference held across edits keeps updating in place. Clone it + * (e.g. `type.delta.clone()`) if you need a stable snapshot, and call {@link YType#clearCache} to + * drop the cache. + * + * @type {delta.Delta} + */ + get delta () { + if (this._delta === null) { + this._delta = this._renderDelta() + } + return /** @type {any} */ (this._delta) + } + + /** + * Render the full deep current state into a fresh `isFinal` builder (so subsequent `.apply`s of + * deep changes update content in place). Uses this type's active renderer. + * + * @return {delta.DeltaBuilderAny} + */ + _renderDelta () { + const state = /** @type {delta.DeltaBuilderAny} */ (delta.create(this.name)) + state.isFinal = true + state.apply(this.toDelta({ deep: true })) + return state + } + + /** + * Discard the cached deep delta backing {@link YType#delta}. + * + * After `delta` is first accessed, the cache is updated on every event fired on this type (and + * re-diffed by {@link YType#useRenderer}). Call this to drop it — e.g. to reclaim memory, or to + * force an exact recomputation after editing while a non-base renderer is active (the incremental + * updates can drift from a fresh deep render in that case). + */ + clearCache () { + this._delta = null + } + + /** + * Change the default renderer used by this type. After calling `useRenderer(renderer)`, the + * `toDelta`, `applyDelta`, and event methods all use `renderer` whenever no explicit renderer is + * passed (an explicit `{ renderer }` argument still overrides it per call). + * + * If the deep-delta cache ({@link YType#delta}) is being maintained, or a `'delta'` listener is + * attached, the content is re-rendered with the new renderer and the difference is emitted on the + * `'delta'` channel only (a renderer switch is not a CRDT change, so no `YEvent` is produced). + * + * @param {AbstractRenderer} renderer + * @return {this} + */ + useRenderer (renderer) { + const prev = this._renderer + const hasDeltaListeners = (this._observers.get('delta')?.size ?? 0) > 0 + if (renderer !== prev && (this._delta !== null || hasDeltaListeners)) { + const oldState = this._delta ?? this._renderDelta() + this._renderer = renderer + const newState = this._renderDelta() + if (this._delta !== null) this._delta = newState + if (hasDeltaListeners) { + const d = /** @type {any} */ (delta.diff(/** @type {any} */ (oldState), /** @type {any} */ (newState))) + if (!d.isEmpty()) this.emit('delta', [d]) + } + } else { + this._renderer = renderer + } + return this + } + + /** + * Tear down this type as an `RDT`: emit the `'destroy'` event and unregister all `'delta'` / + * `'destroy'` listeners. The CRDT content and the `observe`/`observeDeep` handlers are left + * untouched — this only releases the RDT/binding observers. + */ + destroy () { + this.emit('destroy', [this]) + super.destroy() } /** @@ -760,6 +874,9 @@ export class YType { _callObserver (transaction, parentSubs) { const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs) callTypeObservers(/** @type {any} */ (this), transaction, event) + // Note: the RDT `'delta'` channel (and the deep-delta cache) is driven in the transaction + // cleanup's `changedParentTypes` loop (see Transaction.js) so it bubbles to ancestors like + // `observeDeep`, not here where only the directly-changed type is visible. if (!transaction.local && this._searchMarker) { this._searchMarker.length = 0 } @@ -821,7 +938,7 @@ export class YType { * @template {boolean} [Deep=false] * * @param {Object} [opts] - * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer` + * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed * @param {IdSet?} [opts.itemsToRender] * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only @@ -833,7 +950,7 @@ export class YType { * @public */ toDelta (opts = {}) { - const { renderer = baseRenderer, itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, deep = false } = opts + const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, deletedItems = null, deep = false } = opts const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts const renderAttrs = modified?.get(this) || null const renderChildren = modified == null || !modified.has(this) || /** @type {Set} */ (modified.get(this)).has(null) @@ -901,7 +1018,7 @@ export class YType { const renderDelete = c.render && c.deleted // existing content that should be retained, only adding changed attributes const retainContent = !c.render && (!c.deleted || c.attrs != null) - const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : null + const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : undefined switch (c.content.constructor) { case ContentDeleted: { if (renderDelete) d.delete(c.content.getLength()) @@ -912,9 +1029,11 @@ export class YType { d.usedAttributes = currentAttributes usingCurrentAttributes = true if (c.deleted ? retainDeletes : retainInserts) { - d.retain(/** @type {ContentString} */ (c.content).str.length, null, attribution ?? {}) + // change render: a retained item with no attribution means its attribution was + // removed → emit `null` (clear) rather than `{}` (skip). Present attribution merges. + d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? null) } else { - d.insert(/** @type {ContentString} */ (c.content).str, null, attribution) + d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution) } } else if (renderDelete) { d.delete(c.content.getLength()) @@ -935,14 +1054,14 @@ export class YType { if (c.deleted ? retainDeletes : retainInserts) { if (c.deleted && c.content.constructor === ContentType) { // @todo use current transaction instead - d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), null, attribution ?? {}) + d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? null) } else { - d.retain(c.content.getLength(), null, attribution ?? {}) + d.retain(c.content.getLength(), undefined, attribution ?? null) } } else if (deep && c.content.constructor === ContentType) { - d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], null, attribution) + d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], undefined, attribution) } else { - d.insert(c.content.getContent(), null, attribution) + d.insert(c.content.getContent(), undefined, attribution) } } else if (renderDelete) { d.delete(1) @@ -1061,7 +1180,7 @@ export class YType { * attributions. * * @param {Object} [opts] - * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer` + * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed * @return {delta.Delta} */ toDeltaDeep (opts = {}) { @@ -1073,11 +1192,14 @@ export class YType { * * @param {delta.DeltaAny} d The changes to apply on this element. * @param {Object} [opts] - * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to `baseRenderer` + * @param {AbstractRenderer} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `baseRenderer` unless changed + * @return {null} The lib0 `RDT` "fix" of this apply — always `null`: a `YType` accepts every valid + * delta as-is and never needs to self-correct. * * @public */ - applyDelta (d, { renderer = baseRenderer } = {}) { + applyDelta (d, { renderer = this._renderer } = {}) { + if (d.isEmpty()) return null if (this.doc == null) { (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d) } else if (this._item?.deleted !== true) { @@ -1118,7 +1240,7 @@ export class YType { } }) } - return this + return null } /** @@ -1922,25 +2044,26 @@ export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modifi */ const cs = [] renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1) - const { deleted, attrs, content, render } = cs[cs.length - 1] - if (!render) return + if (cs.length === 0) return // the renderer surfaces nothing for this attribute (e.g. a diff renderer hiding an unchanged delete) + const { deleted, attrs, content } = cs[cs.length - 1] const attribution = createAttributionFromAttributionItems(attrs, deleted) let c = array.last(content.getContent()) if (deleted) { - if (itemsToRender == null || itemsToRender.hasId(item.lastId)) { - if (attribution != null) { - // Item surfaced under attribution (suggestion view / diff AM, - // either in snapshot mode or in an event-driven render). The - // attribute is still observable in the rendered state, so emit - // a positive `SetAttrOp` carrying the attribution metadata - - // matching how content children are rendered for the same case - // (positive `InsertOp` with attribution, never `DeleteOp`). + if (attribution != null) { + // Item surfaced under attribution (suggestion view / diff renderer, either in snapshot mode + // or in an event-driven render). The attribute is still observable in the rendered state, so + // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content + // children are rendered for the same case (positive `InsertOp` with attribution, never + // `DeleteOp`). + if (itemsToRender == null || itemsToRender.hasId(item.lastId)) { d.setAttr(key, c, attribution) - } else { - // Hard-deleted attribute (no AM-surfaced attribution): emit the - // change op so event consumers can apply it. - d.deleteAttr(key, attribution, c) } + } else if (itemsToRender != null && itemsToRender.hasId(item.lastId)) { + // Hard-deleted attribute within a change render: emit the `deleteAttr` op so consumers (the + // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In + // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders + // run with `render === false` for such items, so nothing was emitted before either). + d.deleteAttr(key, attribution, c) } } else if (deep && c instanceof YType && modified?.has(c)) { d.modifyAttr(key, c.toDelta(opts)) diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js index 79e481c4..330a5863 100644 --- a/tests/attribution.tests.js +++ b/tests/attribution.tests.js @@ -8,6 +8,9 @@ import * as Y from '../src/index.js' import * as t from 'lib0/testing' import * as delta from 'lib0/delta' +import * as prng from 'lib0/prng' +import * as math from 'lib0/math' +import { bind, $rdt } from 'lib0/delta/rdt' import { init } from './testHelper.js' // eslint-disable-line /** @@ -207,10 +210,346 @@ export const testAttributionChange = () => { renderer.on('change', changes => { calledHandler = true const changeUpdate = ytype.toDelta({ renderer, deep: true, itemsToRender: changes, retainInserts: true, retainDeletes: true }) - const expectedUpdate = delta.create().retain(2).retain(1, null, {}) + // the '!' lost its `{ insert: [] }` suggestion attribution → the change clears it (tri-state `null`) + const expectedUpdate = delta.create().retain(2).retain(1, undefined, null) t.compare(changeUpdate, expectedUpdate) - console.log(changeUpdate.toJSON()) }) Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc)) t.assert(calledHandler) } + +/** + * A YType implements the lib0 `RDT` interface, so two types can be kept in sync with `bind`. + */ +export const testRdtBinding = () => { + const docA = new Y.Doc() + const docB = new Y.Doc() + const a = docA.get('text') + const b = docB.get('text') + const binding = bind(a, b) + // edit A -> propagates to B + a.insert(0, 'hello') + t.assert(b.toString() === 'hello') + // edit B -> propagates back to A (no echo loop) + b.insert(5, ' world') + t.assert(a.toString() === 'hello world') + t.assert(b.toString() === 'hello world') + // after the binding is destroyed, changes no longer propagate + binding.destroy() + a.insert(0, 'x') + t.assert(a.toString() === 'xhello world') + t.assert(b.toString() === 'hello world') +} + +/** + * Local changes are emitted on the `'delta'` channel as the deep delta. + */ +export const testRdtDeltaEvent = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + /** + * @type {any} + */ + let captured = null + ytext.on('delta', d => { captured = d }) + ytext.insert(0, 'hello') + t.compare(captured, delta.create().insert('hello').done()) +} + +/** + * `useRenderer` changes the default renderer used by `toDelta` (and friends). Calling `toDelta()` + * with no argument afterwards is equivalent to passing the renderer explicitly. + */ +export const testUseRenderer = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { + ytext.delete(6, 5) + }) + const renderer = Y.createDiffRenderer(v1, ydoc) + const explicit = ytext.toDelta({ renderer }) + // change the default renderer; toDelta() with no arg now matches the explicit form + ytext.useRenderer(renderer) + const viaDefault = ytext.toDelta() + t.compare(viaDefault, explicit) + t.compare(viaDefault, delta.create().insert('hello ').insert('world', null, { delete: [] }).done()) +} + +/** + * `destroy()` emits the RDT `'destroy'` event, and top-level types are destroyed with their Doc. + */ +export const testRdtDestroy = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + let destroyed = 0 + ytext.on('destroy', () => { destroyed++ }) + ytext.destroy() + t.assert(destroyed === 1) + // a top-level type is torn down when its Doc is destroyed + const ydoc2 = new Y.Doc() + const ytext2 = ydoc2.get('text') + let destroyed2 = 0 + ytext2.on('destroy', () => { destroyed2++ }) + ydoc2.destroy() + t.assert(destroyed2 === 1) +} + +/** + * The `'delta'` event bubbles to ancestors on nested changes, like `observeDeep`. A listener on a + * container fires (with the container-rooted delta) when a nested child is edited. + */ +export const testRdtDeltaBubblesLikeObserveDeep = () => { + const ydoc = new Y.Doc() + const yarray = ydoc.get('arr') + const child = new Y.Type() + yarray.insert(0, [child]) + let containerFired = 0 + let childFired = 0 + /** + * @type {any} + */ + let captured = null + yarray.on('delta', d => { containerFired++; captured = d }) + child.on('delta', () => { childFired++ }) + child.insert(0, 'hi') + // both the edited child and its ancestor container received a 'delta' + t.assert(childFired === 1) + t.assert(containerFired === 1) + // the container-rooted delta is a non-empty (nested modify) change + t.assert(captured !== null && !captured.isEmpty()) +} + +/** + * `get delta()` returns the deep delta and keeps it current on every event of this type, including + * nested-child edits (which apply as a nested `modify`). The returned value is the live cache. + */ +export const testRdtDeltaCacheMaintenance = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + ytext.insert(0, 'hello') + // first access materializes the cache + t.assert(ytext.delta.equals(delta.create().insert('hello').done())) + // a later edit updates the live cache in place + const live = ytext.delta + ytext.insert(5, ' world') + t.assert(live === ytext.delta) // same maintained object + t.assert(ytext.delta.equals(delta.create().insert('hello world').done())) + t.assert(ytext.delta.equals(ytext.toDeltaDeep())) // matches a fresh deep render + + // nested: editing a child updates the container's cached deep delta via a nested modify apply + const yarray = ydoc.get('arr') + const child = new Y.Type() + yarray.insert(0, [child]) + child.insert(0, 'a') + const before = yarray.delta // materialize under base renderer + child.insert(1, 'b') // nested edit after materialization + t.assert(before === yarray.delta) + t.assert(yarray.delta.equals(yarray.toDeltaDeep())) +} + +/** + * `clearCache()` drops the maintained deep delta; the next `delta` access re-materializes it. + */ +export const testRdtClearCache = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + ytext.insert(0, 'hello') + const d1 = ytext.delta + t.assert(ytext._delta !== null) + ytext.clearCache() + t.assert(ytext._delta === null) + const d2 = ytext.delta // re-materialized, a fresh builder + t.assert(d2 !== d1) + t.assert(d2.equals(delta.create().insert('hello').done())) +} + +/** + * `useRenderer` re-renders the maintained delta with the new renderer, emits the difference on the + * `'delta'` channel, and updates the cache. + */ +export const testRdtUseRendererEmitsDiff = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { ytext.delete(6, 5) }) + // materialize the cache under the base renderer + t.assert(ytext.delta.equals(delta.create().insert('hello ').done())) + /** + * @type {any} + */ + let captured = null + ytext.on('delta', d => { captured = d }) + ytext.useRenderer(Y.createDiffRenderer(v1, ydoc)) + // a non-empty rendering diff was emitted only on the 'delta' channel + t.assert(captured !== null && !captured.isEmpty()) + // and the cache now reflects the diff-rendered state + t.assert(ytext.delta.equals(delta.create().insert('hello ').insert('world', null, { delete: [] }).done())) +} + +/** + * `YType` conforms to the lib0 `RDT` interface — verified at runtime with `$rdt.check` (replaces the + * old compile-time `_assertYTypeIsRdt`). + */ +export const testRdtConformsToRdtSchema = () => { + t.assert($rdt.check(new Y.Doc().get())) + t.assert($rdt.check(new Y.Type())) + t.assert(!$rdt.check({})) + t.assert(!$rdt.check(null)) +} + +/** + * Collect a type and all of its (non-deleted) nested `YType` descendants. + * + * @param {Y.Type} root + * @return {Array>} + */ +const collectTypes = root => { + /** + * @type {Array>} + */ + const out = [root] + for (let i = 0; i < out.length; i++) { + out[i].forEach(c => { if (c instanceof Y.Type) out.push(c) }) + out[i].forEachAttr(v => { if (v instanceof Y.Type) out.push(v) }) + } + return out +} + +/** + * Apply one random mutation to a random type in the tree rooted at `root`. + * + * @param {prng.PRNG} gen + * @param {Y.Type} root + * @param {boolean} [includeFormat] - include `format` ops. Excluded under a diffing renderer because + * *removing* a format from attributed content still desyncs the maintained diff-attributed `delta`. + * lib0's `apply` is correct (a valid change exists: `apply(prev, delta.diff(prev, next)) === next`); + * the bug is in YJS's `toDelta` change-computation (the ContentFormat formatting-attribution block, + * src/ytype.js ~L1076-1166): un-formatting emits an invalid deep change — wrong range, spurious + * `{attribution:{format:[…]}}` re-asserts, and no `attribution:{format:null}` clear — instead of the + * correct `retain(n, { format: null, attribution: { format: null } })`. Format under the + * (unattributed) base renderer is exercised separately (`testRdtDeltaFuzz`). + */ +const applyRandomYTypeOp = (gen, root, includeFormat = true) => { + const target = prng.oneOf(gen, collectTypes(root)) + switch (prng.int32(gen, 0, 5)) { + case 0: // insert text + target.insert(prng.int32(gen, 0, target.length), prng.word(gen)) + break + case 1: // insert a nested type + target.insert(prng.int32(gen, 0, target.length), [new Y.Type()]) + break + case 2: // delete a range + if (target.length > 0) { + const p = prng.int32(gen, 0, target.length - 1) + target.delete(p, prng.int32(gen, 1, math.min(3, target.length - p))) + } + break + case 3: // format a range (skipped when format is excluded — see @param includeFormat) + if (includeFormat && target.length > 0) { + const p = prng.int32(gen, 0, target.length - 1) + target.format(p, prng.int32(gen, 1, math.min(3, target.length - p)), { bold: prng.bool(gen) ? true : null }) + } + break + case 4: // set / delete a map attribute + if (prng.bool(gen)) { + target.setAttr(prng.oneOf(gen, ['a', 'b', 'c']), prng.word(gen)) + } else { + target.deleteAttr(prng.oneOf(gen, ['a', 'b', 'c'])) + } + break + } +} + +/** + * Fuzz: after each random mutation, every type's maintained `delta` cache (at every nesting level) + * must equal a fresh deep render `toDelta({ deep: true })`. + * + * @param {t.TestCase} tc + */ +export const testRdtDeltaFuzz = tc => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + for (let i = 0; i < 300; i++) { + applyRandomYTypeOp(tc.prng, root) + collectTypes(root).forEach(type => + t.assert(type.delta.equals(type.toDelta({ deep: true })), `iter ${i}`)) + } +} + +/** + * Fuzz under a diffing renderer, across two synced replicas. Each replica is a "suggestion doc" that + * diffs against its own fixed baseline clone (taken after some shared initial content). With the plain + * diff renderer (no `attrs`), suggestion inserts render `{ insert: [] }` and deletes render + * `{ delete: [] }` — identical on every replica — so the maintained, diff-attributed `delta` must + * converge across replicas (and match a fresh deep render). The cache is kept current purely by the + * `'delta'` event (no recompute). + * + * @param {t.TestCase} tc + */ +export const testRdtDeltaSuggestionConvergence = tc => { + const { testConnector, users } = init(tc, { users: 2 }) + const [d0, d1] = users + d0.get('root').insert(0, 'shared baseline content') + testConnector.flushAllMessages() + // each replica diffs against its own fixed baseline clone (plain diff renderer => {insert:[]}/{delete:[]}) + d0.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d0), d0)) + d1.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d1), d1)) + for (let i = 0; i < 300; i++) { + applyRandomYTypeOp(tc.prng, prng.oneOf(tc.prng, users).get('root'), false) // exclude format (lib0 apply bug) + testConnector.flushAllMessages() + const a = d0.get('root').delta + const b = d1.get('root').delta + t.assert(a.equals(b), `converge iter ${i}`) // the suggestion view is replica-independent + t.assert(a.equals(d0.get('root').toDelta({ deep: true })), `canonical iter ${i}`) // and matches a fresh render + } +} + +/** + * Sanity: the maintained `delta` equals both an explicit expected delta and a fresh deep render — + * for flat content, nested children, and ongoing edits. + */ +export const testRdtDeltaSanity = () => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + root.insert(0, 'hello') + root.setAttr('k', 'v') + t.assert(root.delta.equals(delta.create().insert('hello').setAttr('k', 'v').done())) + t.assert(root.delta.equals(root.toDelta({ deep: true }))) + // nested child + ongoing edits keep delta == fresh deep render + const child = new Y.Type() + root.insert(5, [child]) + child.insert(0, 'world') + t.assert(root.delta.equals(root.toDelta({ deep: true }))) + child.insert(5, '!') + root.delete(0, 1) + t.assert(root.delta.equals(root.toDelta({ deep: true }))) + // the nested child's own cache is consistent too + t.assert(child.delta.equals(child.toDelta({ deep: true }))) + t.assert(child.delta.equals(delta.create().insert('world!').done())) +} + +/** + * Sanity: under a diffing-attribution renderer the maintained `delta` carries the expected + * attribution markers and equals a fresh attributed deep render. + */ +export const testRdtDeltaAttributionSanity = () => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + const v1 = Y.cloneDoc(ydoc) + const attrs = new Y.Attributions() + ydoc.on('update', (update, _origin, doc, tr) => { + if (!tr.local) return + const uid = doc.clientID.toString() + const cids = Y.createContentIdsFromUpdate(update) + Y.insertIntoIdMap(attrs.inserts, Y.createIdMapFromIdSet(cids.inserts, [Y.createContentAttribute('insert', uid)])) + Y.insertIntoIdMap(attrs.deletes, Y.createIdMapFromIdSet(cids.deletes, [Y.createContentAttribute('delete', uid)])) + }) + root.insert(0, 'hello') // a suggestion relative to v1 + const uid = ydoc.clientID.toString() + root.useRenderer(Y.createDiffRenderer(v1, ydoc, { attrs })) + t.assert(root.delta.equals(delta.create().insert('hello', null, { insert: [uid] }).done())) + t.assert(root.delta.equals(root.toDelta({ deep: true }))) +} diff --git a/tests/y-map.tests.js b/tests/y-map.tests.js index 59c3d351..90883c91 100644 --- a/tests/y-map.tests.js +++ b/tests/y-map.tests.js @@ -98,7 +98,9 @@ export const testMapHavingIterableAsConstructorParamTests = tc => { map0.setAttr('m2', m2) t.assert(m2.getAttr('object')?.x === 1) t.assert(m2.getAttr('boolean') === true) - const m3 = new Y.Type().applyDelta(m1.toDelta()).applyDelta(m2.toDelta()) + const m3 = new Y.Type() + m3.applyDelta(m1.toDelta()) + m3.applyDelta(m2.toDelta()) map0.setAttr('m3', m3) t.assert(m3.getAttr('number') === 1) t.assert(m3.getAttr('string') === 'hello')