YType is an RDT

This commit is contained in:
Kevin Jahns
2026-06-30 10:19:20 +02:00
parent 92e90b651b
commit 856d5b29ba
8 changed files with 686 additions and 211 deletions

324
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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) {

View File

@@ -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<any>}
*/
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<any>}
*/
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]))

View File

@@ -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<DConf> : Delta<import('../ytype.js').DeltaConfDeltaToYType<DConf>>} 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

View File

@@ -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<ContentAttribute<any>>?} 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<DConf>) => void, destroy: (type: YType<DConf>) => void }>}
*/
export class YType {
export class YType extends ObservableV2 {
/**
* @param {delta.DeltaConfGetName<DConf>?} name
*/
constructor (name = null) {
super()
/**
* @type {delta.DeltaConfGetName<DConf>}
*/
@@ -675,10 +688,12 @@ export class YType {
*/
this._searchMarker = null
/**
* @type {delta.DeltaBuilder<DConf>}
* @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<ArraySearchMarker>|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<delta.Delta<DConf>>}
*/
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<DConf>}
*/
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<string|null>} */ (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<DConf>}
*/
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))

View File

@@ -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<any>} root
* @return {Array<Y.Type<any>>}
*/
const collectTypes = root => {
/**
* @type {Array<Y.Type<any>>}
*/
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<any>} 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 })))
}

View File

@@ -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')