diff --git a/.changeset/lexical-y-yjs-version-guard.md b/.changeset/lexical-y-yjs-version-guard.md new file mode 100644 index 00000000000..a00ab21a7fa --- /dev/null +++ b/.changeset/lexical-y-yjs-version-guard.md @@ -0,0 +1,23 @@ +--- +'@lexical/y': patch +--- + +[feature] Refuse a Yjs that lacks the internals `@lexical/y` uses, or a document from a second copy of Yjs, with a typed `YUnsupportedYjsError` before anything is written + +`@lexical/y` supports exactly one `@y/y` version, its exact peer dependency, +now also exported as `SUPPORTED_YJS_VERSION`. The exact version is enforced +by that peer dependency: `@y/y` exposes no version at run time. Package +managers only warn about an unmet peer dependency (pnpm by default, npm +with `--legacy-peer-deps`), so an app could still run another `@y/y` and +misbehave without an error. `createYDoc`, `YBinding` (and so +`YCollabExtension`) and the headless helpers now check, once per app, that +the `@y/y` they loaded provides the exports, fields and events the binding +uses, and that the document comes from that same copy. Otherwise they throw a +`YUnsupportedYjsError` (`code: UNSUPPORTED_YJS_ERROR`) with +`data: {supported, found, reason}`, where `reason` is `'changed-internals'` +or `'other-copy'`. A `@y/y` older than `14.0.0-rc.25` fails earlier, at +build or load, with a missing `Node` export; `rc.25` to `rc.28` provide +everything the binding uses and pass the check, so pin the named version. + +Verify: with `@y/y@14.0.0-rc.29` installed once (`npm ls @y/y`), nothing +changes. diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 3f982299330..dbd04a99e5f 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -48,8 +48,41 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \ @y/y@14.0.0-rc.29 @y/websocket@4.0.0-rc.2 ``` -`@y/y` is a peer dependency pinned to the release candidate `@lexical/y` is -tested with; npm's `latest` tag points at an older one, so name the version. +**`@lexical/y` supports exactly one Yjs version: install the `@y/y` +version it names** (its exact peer dependency, also exported as +`SUPPORTED_YJS_VERSION`), and only one copy of it. npm's `latest` tag points +at an older release candidate, so name the version. The exact version is +enforced by that peer dependency: `@y/y` exposes no version at run time, so +`@lexical/y` cannot compare it there. What it checks at run time is the +Yjs it writes through: `createYDoc`, `YBinding` (and so `YCollabExtension`) +and the headless helpers check, once per app, that the `@y/y` they loaded +provides the exports, fields and events the binding uses, and throw a +`YUnsupportedYjsError` before anything is written when it does not: + +- `data.reason: 'changed-internals'`: the app runs a `@y/y` without + something the binding uses (pnpm only warns about an unmet peer + dependency, and npm installs one with `--legacy-peer-deps`); `data.found` + lists what differs. +- `data.reason: 'other-copy'`: the document (or `root`) comes from a second + copy of `@y/y`. + +A release candidate older than `14.0.0-rc.25` (npm's `latest` among them) +fails earlier, when the app is built or loaded, because it lacks the +`Node` export: esbuild reports `No matching export in +"node_modules/@y/y/src/index.js" for import "Node"` (other bundlers word it +their own way), and native ES modules throw `SyntaxError: The requested module +'@y/y' does not provide an export named 'Node'`. One that provides +everything the binding uses passes the check (`14.0.0-rc.25` to `rc.28` +do), and so would a later one that kept the same shape with different +behaviour, which is why the peer dependency names one version. + +The fix in every case is the same: install `@y/y@` once +(`npm ls @y/y` shows one version, deduped) and create documents with +`createYDoc`. There is no option to skip the check. The check makes two +scratch documents, once, and each draws a client id: a test that seeds +Yjs's randomness to replay documents sees the random ids of later +documents shift (give those documents explicit client ids). + The type declarations `@y/y` ships do not type-check on their own (an upstream issue): if you run `tsc`, set `skipLibCheck: true`. Lexical's types also need TypeScript 5.2 or later with `moduleResolution` set to `bundler`, @@ -1175,6 +1208,14 @@ not merge as a user might expect: place (or replaced by a block node) sends it to the start of the line after, or to the end of the line before where a block node follows, never onto the block node. +- **Yjs versions the run-time check cannot tell apart.** `@y/y` exposes no + version at run time, so `@lexical/y` refuses a `@y/y` that lacks an + export, member or event it uses (see [Install](#install)), but one with + the same ones passes (`14.0.0-rc.25` to `rc.28` do), even if its + functions behave differently, and so does a provider that bundles its own + copy of Yjs and applies updates to your document after the binding + started. The exact version is the peer dependency's to enforce: install + the `@y/y` version `@lexical/y` names, once (`npm ls @y/y`). - **NodeState key order.** Other clients (and any later reader) get a node's NodeState with its keys in sorted order, which every client builds the same way, not in the order the author's editor exported them (a Yjs map diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index c6f0bd2421e..5393d6811fd 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -40,7 +40,7 @@ issues and docs that carry it. | D11 | **The self-check is off by default.** When enabled it reports mismatches as warnings. | It costs time on every change; it is a debugging aid for tests and development. | `DECIDED` | 10-22 | [y.md: Set up an editor][y-setup] | | D12 | **`@lexical/yjs` v1 and v2 get no more fixes.** Their deprecation goes into the docs when `@lexical/y` leaves `@experimental`, with no announcement before that. One v2 table fix, [#9584], merged on 10-30 before this row was checked; later v1 and v2 fix pull requests ([#9595], [#9596]) were closed under it. | Effort goes to the new binding; removing either would break users (D01). | `DECIDED` | 10-22 | [#9413], [#9584], [#9595], [#9596] | | D13 | **The Yjs 14 release candidate is pinned exactly** (`@y/y` `14.0.0-rc.29`) until Yjs 14 is final. Every bump re-checks the Yjs internals the binding reads (D14). | Release candidates change behaviour between versions, and the binding writes through Yjs internals (D14). | `DECIDED` | 10-23 | [#9400], [package.json](./package.json) | -| D14 | **`@lexical/y` is coupled to one Yjs version on purpose, and may use Lexical and Yjs internals.** `@y/y` is an exact peer dependency (`14.0.0-rc.29`), bumped only deliberately and with the equivalence test, so a product cannot bump Yjs by accident and break the binding. The binding writes through its own cursor on Yjs items, checked against the public `applyDelta` by a randomized equivalence test, and uses Lexical internals where no public API exists. A runtime guard on the Yjs version is a follow-up. | Yjs 14's public insert walks a container from its start on every call, which gets slower as documents grow; a positioned insert upstream would remove the need. Lexical controls which Yjs it supports. | `DECIDED` | 10-30 | [#9400], [#9504], [#9510], [package.json](./package.json) | +| D14 | **`@lexical/y` is coupled to one Yjs version on purpose, and may use Lexical and Yjs internals.** `@y/y` is an exact peer dependency (`14.0.0-rc.29`), bumped only deliberately and with the equivalence test, so a product cannot bump Yjs by accident and break the binding. The binding writes through its own cursor on Yjs items, checked against the public `applyDelta` by a randomized equivalence test, and uses Lexical internals where no public API exists. A runtime guard enforces the coupling: `createYDoc`, the `YBinding` constructor (so `YCollabExtension` does not build) and the headless helpers throw a typed `YUnsupportedYjsError` before anything is written, with no opt-out, when the `@y/y` the app loaded lacks an export, member or event the binding uses (checked once per module; `YjsContract.ts`, kept equal to the sources' uses by a test) or the document comes from a second copy. Unlike D07's read-only warning for remote data, a wrong install is the app's own and stops the editor. | Yjs 14's public insert walks a container from its start on every call, which gets slower as documents grow; a positioned insert upstream would remove the need. Lexical controls which Yjs it supports. | `DECIDED` | 10-30 | [#9400], [#9504], [#9510], [#9607], [package.json](./package.json) | | D15 | **53-bit client ids by default; `clientIDRange: 'uint32'` is opt-in** for servers on yrs before 0.28. | yrs before 0.28 reads client ids as 32-bit, and a reconnecting author's content then duplicates. The option is small, so it stays until such servers are gone. | `DECIDED` | 10-23 | [y.md: Servers][y-servers] | ## Storage model @@ -114,6 +114,7 @@ issues and docs that carry it. | D56 | **Initial content that differs between clients shows twice** (random ids, different block id configurations, a root that held content). | Seeding merges only identical bytes (D22); seed in one place. | `ACCEPTED LIMIT` | 10-25 | [limits] | | D57 | **Lone surrogates** reach other clients as U+FFFD. | Yjs encodes text as UTF-8; Yjs 13 does the same. Malformed text such as a broken emoji is the author's own input, not an editor problem, so there is no code follow-up. | `ACCEPTED LIMIT` | 10-30 | [limits] | | D58 | **Inline elements with their own content** (`MarkNode`, `AutoLinkNode`) move text into them when they wrap it, so concurrent typing there stays outside. | Use NodeState marks (D37). | `ACCEPTED LIMIT` | 10-20 | [limits] | +| D79 | **The Yjs guard (D14) cannot tell every other Yjs apart:** `@y/y` exposes no version at run time, so the guard checks shape and the peer dependency enforces the exact version; a `@y/y` with the same exports, members and events passes (`14.0.0-rc.25` to `rc.28` do), as does one whose functions behave differently with the same shape, and a provider that bundles its own copy of Yjs and applies updates to the app's document after the binding started is not caught. | `@y/y` exposes no version at run time, and checking updates would cost on every one. The exact peer pin and the docs (install the named version once; `npm ls @y/y`) cover them. | `ACCEPTED LIMIT` | 10-30 | [#9607], [limits] | ## Open @@ -193,6 +194,7 @@ issues and docs that carry it. [#9599]: https://github.com/meta/lexical/issues/9599 [#9603]: https://github.com/meta/lexical/issues/9603 [#9605]: https://github.com/meta/lexical/pull/9605 +[#9607]: https://github.com/meta/lexical/pull/9607 [#9608]: https://github.com/meta/lexical/issues/9608 [#9609]: https://github.com/meta/lexical/issues/9609 [#9610]: https://github.com/meta/lexical/issues/9610 diff --git a/packages/lexical-y/README.md b/packages/lexical-y/README.md index 50a2068d2c7..aaff30ad9f6 100644 --- a/packages/lexical-y/README.md +++ b/packages/lexical-y/README.md @@ -13,9 +13,17 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \ @lexical/rich-text@nightly lexical@nightly @y/y@14.0.0-rc.29 ``` -`@y/y` is a peer dependency pinned to the release candidate this package is -tested with. Its type declarations do not type-check on their own (an -upstream issue): projects that run `tsc` need `skipLibCheck: true`. +`@y/y` is a peer dependency pinned to the one release candidate this package +supports: install exactly that version, once. The peer dependency enforces +the version (`@y/y` exposes none at run time); at run time `createYDoc`, +`YBinding` and the headless helpers throw a `YUnsupportedYjsError` +(`data.supported` names the version) when the `@y/y` the app loaded lacks +what the binding uses, or the document comes from a second copy. A release +candidate older than `14.0.0-rc.25` (npm's `latest` among them) fails +earlier, at build or load, with a missing `Node` export. `@y/y`'s type +declarations do not +type-check on their own (an upstream issue): projects that run `tsc` need +`skipLibCheck: true`. Until a Lexical release includes `@lexical/y`, npm's `latest` for it is a placeholder: install every Lexical package from the same nightly, in one @@ -103,6 +111,22 @@ To replay one seed with its trace: `FUZZ_SEED=` (add `FUZZ_GC=1` for the garbage-collected run), `NORMALIZE_FUZZ_TRACE=` or `STORAGE_FUZZ_TRACE=`. +## Bumping `@y/y` + +The binding writes through Yjs internals, so a new `@y/y` is a deliberate +change, never a range: + +1. Set the exact version in both `peerDependencies` and `devDependencies`, + then run `pnpm install` and `pnpm run update-version` (which regenerates + `SUPPORTED_YJS_VERSION` in `src/internal/SupportedYjs.ts`). +2. Run `YjsContract.test.ts`: update the exports, members and events in + `src/internal/YjsContract.ts` to what the binding uses of the new + version, and re-check each changed one in the code that uses it. +3. Run the equivalence test (`Cursor.test.ts`, the binding's cursor against + `Y.Node#applyDelta`), the catalog matrix (`src/__tests__/unit/matrix`) + and the long fuzz above, then update the version in the install + commands here and in the docs (`docs/collaboration/y.md`). + ## Storage The shared root is `doc.get('lexical-y')` (`DEFAULT_ROOT_NAME`). How an diff --git a/packages/lexical-y/flow/LexicalY.js.flow b/packages/lexical-y/flow/LexicalY.js.flow index 26caf2d1f09..4078f4bbe4d 100644 --- a/packages/lexical-y/flow/LexicalY.js.flow +++ b/packages/lexical-y/flow/LexicalY.js.flow @@ -202,6 +202,28 @@ export type YDocOptions = { shouldLoad?: boolean, }; +// The one @y/y version this @lexical/y supports (its exact peer dependency). +declare export var SUPPORTED_YJS_VERSION: string; + +declare export var UNSUPPORTED_YJS_ERROR: '@lexical/y/unsupported-yjs'; + +export type YUnsupportedYjsReason = 'changed-internals' | 'other-copy'; + +export type YUnsupportedYjsData = { + readonly supported: string, + readonly found: ReadonlyArray, + readonly reason: YUnsupportedYjsReason, +}; + +// Thrown by createYDoc, the YBinding constructor (so YCollabExtension) and +// the headless helpers when the app's @y/y is not the supported one, before +// anything is written. Fix: install exactly @y/y@, once. +declare export class YUnsupportedYjsError extends Error { + readonly code: typeof UNSUPPORTED_YJS_ERROR; + readonly data: YUnsupportedYjsData; + constructor(data: YUnsupportedYjsData): void; +} + // A Y.Doc for @lexical/y (a client's, a relay's or a server's), with Yjs's // format cleanup turned off before `state` is applied (when given). declare export function createYDoc( diff --git a/packages/lexical-y/flow/LexicalYHeadless.js.flow b/packages/lexical-y/flow/LexicalYHeadless.js.flow index 512c1c7f695..3a4d7ad882c 100644 --- a/packages/lexical-y/flow/LexicalYHeadless.js.flow +++ b/packages/lexical-y/flow/LexicalYHeadless.js.flow @@ -72,6 +72,10 @@ declare export class YHeadlessError extends Error { ): void; } +// The same as @lexical/y's: thrown when the app's @y/y is not the one +// @lexical/y supports, before anything is written. +export {YUnsupportedYjsError} from '@lexical/y'; + // The same as @lexical/y's createYDoc: a Y.Doc for a relay or a server that // loads a stored state, with Yjs's format cleanup turned off as on the // clients, and `state` applied when given. diff --git a/packages/lexical-y/flow/__flowtests__/unsupportedYjs.js.flow b/packages/lexical-y/flow/__flowtests__/unsupportedYjs.js.flow new file mode 100644 index 00000000000..2286563db6b --- /dev/null +++ b/packages/lexical-y/flow/__flowtests__/unsupportedYjs.js.flow @@ -0,0 +1,54 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Checked by `pnpm run flow` (not shipped): YUnsupportedYjsError's data is +// typed, and its reasons are exhaustive. + +import type {YUnsupportedYjsData} from '@lexical/y'; + +import { + SUPPORTED_YJS_VERSION, + UNSUPPORTED_YJS_ERROR, + YUnsupportedYjsError, +} from '@lexical/y'; +import {YUnsupportedYjsError as HeadlessYUnsupportedYjsError} from '@lexical/y/headless'; + +// The headless entry re-exports the same class. +const SameClass: typeof YUnsupportedYjsError = HeadlessYUnsupportedYjsError; +void SameClass; + +function fixFor(error: unknown): string | null { + if (!(error instanceof YUnsupportedYjsError)) { + return null; + } + const code: '@lexical/y/unsupported-yjs' = error.code; + const data: YUnsupportedYjsData = error.data; + switch (data.reason) { + case 'changed-internals': + return `install @y/y@${data.supported} (found: ${data.found.join(', ')}; ${code})`; + case 'other-copy': + return `dedupe @y/y@${data.supported}`; + default: + data.reason as empty; + return null; + } +} + +const supported: string = SUPPORTED_YJS_VERSION; +const errorCode: '@lexical/y/unsupported-yjs' = UNSUPPORTED_YJS_ERROR; +fixFor( + new YUnsupportedYjsError({ + found: ['Item#parentSub'], + reason: 'changed-internals', + supported, + }), +); +// $FlowExpectedError[incompatible-type] the reasons are a closed set +new YUnsupportedYjsError({found: [], reason: 'newer', supported}); +void errorCode; diff --git a/packages/lexical-y/src/__tests__/unit/Source.test.ts b/packages/lexical-y/src/__tests__/unit/Source.test.ts index 689f215475c..e35bb3b87a2 100644 --- a/packages/lexical-y/src/__tests__/unit/Source.test.ts +++ b/packages/lexical-y/src/__tests__/unit/Source.test.ts @@ -63,7 +63,13 @@ test('internal writes of node attributes go through Seen', () => { const dir = path.resolve(__dirname, '../../internal'); const found: string[] = []; for (const name of fs.readdirSync(dir).sort()) { - if (!name.endsWith('.ts') || name === 'Seen.ts') { + // YjsContract.ts probes @y/y on scratch documents of its own, never a + // document a binding reads, so its writes have no Seen record to keep. + if ( + !name.endsWith('.ts') || + name === 'Seen.ts' || + name === 'YjsContract.ts' + ) { continue; } const file = ts.createSourceFile( @@ -169,15 +175,18 @@ test('public entries', () => { 'RECOVERED_WRITE_WARNING', 'SELF_CHECK_WARNING', 'STORAGE_MISMATCH_WARNING', + 'SUPPORTED_YJS_VERSION', 'UNKNOWN_NODE_TYPES_WARNING', 'UNSUPPORTED_CONFIG_WARNING', 'UNSUPPORTED_EDIT_WARNING', 'UNSUPPORTED_LAYOUT_WARNING', 'UNSUPPORTED_SCHEMA_WARNING', 'UNSUPPORTED_SLOTS_WARNING', + 'UNSUPPORTED_YJS_ERROR', 'UNSYNCED_VALUE_WARNING', 'YBinding', 'YCollabExtension', + 'YUnsupportedYjsError', 'createYDoc', 'describeStorage', 'isYWarning', @@ -187,6 +196,7 @@ test('public entries', () => { 'FOLLOW_UPS_EXHAUSTED_WARNING', 'WRITER_NEW_CLIENT_ID_WARNING', 'YHeadlessError', + 'YUnsupportedYjsError', 'createYDoc', 'createYWriter', 'editYState', diff --git a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts new file mode 100644 index 00000000000..e98cdb4ba3a --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts @@ -0,0 +1,930 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +// The runtime guard on the Yjs @lexical/y runs against (YjsContract): the +// contract lists match what the sources use of @y/y, the supported version +// is the exact peer dependency, a matching @y/y passes, and a different one +// (a missing export, member or event, a changed arity, a document from +// another copy) is refused with a typed error before anything is written, +// by every entry point. The check runs once per module. + +import * as Y from '@y/y'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import ts from 'typescript'; +import {afterEach, describe, expect, expectTypeOf, test, vi} from 'vitest'; + +import { + SUPPORTED_YJS_VERSION, + UNSUPPORTED_YJS_ERROR, + type YUnsupportedYjsData, + YUnsupportedYjsError, +} from '../..'; +import { + probeYjs, + YJS_EVENTS, + YJS_EXPORTS, + yjsMembers, +} from '../../internal/YjsContract'; + +const PACKAGE_DIR = path.resolve(__dirname, '../../..'); +const SRC_DIR = path.join(PACKAGE_DIR, 'src'); +const REPO_DIR = path.resolve(PACKAGE_DIR, '../..'); + +function readJson(file: string): Record> { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +describe('the supported version', () => { + test('is the exact @y/y peer dependency, the dev dependency and the installed @y/y', () => { + const pkg = readJson(path.join(PACKAGE_DIR, 'package.json')); + const installed = readJson( + path.join(PACKAGE_DIR, 'node_modules/@y/y/package.json'), + ); + // A mismatch with package.json: run `pnpm run update-version`, which + // regenerates src/internal/SupportedYjs.ts. + expect(SUPPORTED_YJS_VERSION).toBe(pkg.peerDependencies['@y/y']); + expect(pkg.devDependencies['@y/y']).toBe(SUPPORTED_YJS_VERSION); + expect(installed.version).toBe(SUPPORTED_YJS_VERSION); + expect(SUPPORTED_YJS_VERSION).toMatch(/^\d+\.\d+\.\d+(-[\w.]+)?$/); + }); +}); + +/** + * A program over `files` in which the types @y/y's declarations name + * without importing them (`Transaction#doc: Doc`, `RelativePosition#item: + * ID | null`: the reason apps need `skipLibCheck`) resolve, so that values + * of those types are not `any` and their members are seen. The names are + * declared as globals in a virtual file, for this test only. + */ +function programWithResolvedYjsTypes( + files: string[], + options: ts.CompilerOptions, + yDir: string, +): ts.Program { + const base = {...options, noEmit: true}; + // skipLibCheck would hide the missing names. + const first = ts.createProgram(files, {...base, skipLibCheck: false}); + const inYDir = (sf: ts.SourceFile) => + fs.realpathSync(sf.fileName).startsWith(yDir); + const missing = new Set(); + for (const sf of first.getSourceFiles()) { + if (inYDir(sf)) { + for (const d of first.getSemanticDiagnostics(sf)) { + const m = ts + .flattenDiagnosticMessageText(d.messageText, '\n') + .match(/^Cannot find name '(\w+)'/); + if (m) { + missing.add(m[1]); + } + } + } + } + // Where @y/y declares each missing name, and how many type parameters + // it takes. + const declared = new Map(); + for (const sf of first.getSourceFiles()) { + if (!inYDir(sf)) { + continue; + } + for (const statement of sf.statements) { + if ( + (ts.isClassDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement)) && + statement.name && + missing.has(statement.name.text) && + !declared.has(statement.name.text) && + (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0 + ) { + declared.set(statement.name.text, { + file: sf.fileName.replace(/\.d\.ts$/, ''), + params: statement.typeParameters + ? statement.typeParameters.length + : 0, + }); + } + } + } + const lines = [...declared].map(([name, {file, params}]) => { + const args = Array.from({length: params}, (_, i) => `T${i}`); + const formal = args.map(a => `${a} = any`).join(', '); + return params === 0 + ? ` type ${name} = import(${JSON.stringify(file)}).${name};` + : ` type ${name}<${formal}> = import(${JSON.stringify(file)}).${name}<${args.join(', ')}>;`; + }); + const virtual = path.join(PACKAGE_DIR, '__yjs_contract_globals__.d.ts'); + const text = `export {};\ndeclare global {\n${lines.join('\n')}\n}\n`; + const host = ts.createCompilerHost(base); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, ...rest) => + fileName === virtual + ? ts.createSourceFile(fileName, text, languageVersion) + : getSourceFile(fileName, languageVersion, ...rest); + const fileExists = host.fileExists.bind(host); + host.fileExists = fileName => fileName === virtual || fileExists(fileName); + return ts.createProgram([...files, virtual], base, host, first); +} + +/** + * Everything the package's sources (not tests) use of @y/y, as the + * TypeScript checker resolves it: `import X` for value imports, `new X`, + * `Owner#member` for a member read or called on a value whose type @y/y + * declares (or a member @y/y declares), destructured fields and option + * properties, and `Owner@event` for an `on`/`off`/`once` event name. + * Anonymous types are named by their declaration in @y/y + * (`decodeUpdate#structs`). + */ +function inventory(): Map> { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!entry.name.startsWith('__')) { + walk(p); + } + } else if ( + /\.tsx?$/.test(entry.name) && + !/\.(test|bench)\.tsx?$/.test(entry.name) && + // The contract's own probe is not a use. + entry.name !== 'YjsContract.ts' + ) { + files.push(p); + } + } + }; + walk(SRC_DIR); + const parsed = ts.getParsedCommandLineOfConfigFile( + path.join(REPO_DIR, 'tsconfig.json'), + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: d => { + throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n')); + }, + }, + ); + if (parsed === undefined) { + throw new Error('tsconfig.json did not parse'); + } + const yDir = fs.realpathSync(path.join(PACKAGE_DIR, 'node_modules/@y/y')); + const program = programWithResolvedYjsTypes(files, parsed.options, yDir); + const checker = program.getTypeChecker(); + const inY = (decl: ts.Node) => + fs.realpathSync(decl.getSourceFile().fileName).startsWith(yDir); + const nameOf = (decl: ts.Node): string => { + const parts: string[] = []; + for (let p: ts.Node | undefined = decl; p && !ts.isSourceFile(p); ) { + const name = (p as {name?: ts.Node}).name; + if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) { + parts.unshift(name.text); + if (parts.length === 2) { + break; + } + } + p = p.parent; + } + return parts.join('.') || '?'; + }; + // The @y/y types a value's type is or extends: through unions, + // intersections, type parameters' constraints and the base types of a + // local interface or class (`YDoc extends Doc` is a `Doc`). + const owners = (type: ts.Type, seen = new Set()): string[] => { + if (seen.has(type)) { + return []; + } + seen.add(type); + if (type.isUnionOrIntersection()) { + return type.types.flatMap(t => owners(t, seen)); + } + if (type.flags & ts.TypeFlags.TypeParameter) { + const constraint = checker.getBaseConstraintOfType(type); + return constraint ? owners(constraint, seen) : []; + } + const symbol = type.aliasSymbol || type.getSymbol(); + const decl = symbol && symbol.declarations && symbol.declarations[0]; + if (symbol && decl && inY(decl)) { + return [ + symbol.name === '__type' || symbol.name === '__object' + ? nameOf(decl) + : symbol.name, + ]; + } + if ( + symbol && + type.flags & ts.TypeFlags.Object && + (type as ts.ObjectType).objectFlags & ts.ObjectFlags.ClassOrInterface + ) { + return (checker.getBaseTypes(type as ts.InterfaceType) || []).flatMap(t => + owners(t, seen), + ); + } + return []; + }; + // Whether `node` is (an expression whose root is) a value @y/y declares: + // a member access on such a value. + const fromY = (node: ts.Expression): boolean => { + if (owners(checker.getTypeAtLocation(node)).length > 0) { + return true; + } + if (ts.isPropertyAccessExpression(node)) { + const symbol = checker.getSymbolAtLocation(node.name); + const decl = symbol && symbol.declarations && symbol.declarations[0]; + return (decl !== undefined && inY(decl)) || fromY(node.expression); + } + return false; + }; + // Whether `node`, typed `any`, holds a value from @y/y: a member @y/y + // declares (`tr.doc`, whose declared type does not resolve), a call of a + // method @y/y declares, an untyped local initialized from either, or an + // untyped parameter of a callback given to a method of a value from @y/y + // (`tr.changed.forEach((subs, type) => ...)`). + const fromYUntyped = (node: ts.Expression): boolean => { + if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) { + return fromYUntyped(node.expression); + } + if (ts.isPropertyAccessExpression(node)) { + const symbol = checker.getSymbolAtLocation(node.name); + const decl = symbol && symbol.declarations && symbol.declarations[0]; + return decl !== undefined && inY(decl); + } + if (ts.isCallExpression(node)) { + return ( + ts.isPropertyAccessExpression(node.expression) && + (fromYUntyped(node.expression) || fromY(node.expression.expression)) + ); + } + if (!ts.isIdentifier(node)) { + return false; + } + const symbol = checker.getSymbolAtLocation(node); + const decl = symbol && symbol.valueDeclaration; + if (!decl) { + return false; + } + if (ts.isVariableDeclaration(decl)) { + return ( + !decl.type && + decl.initializer !== undefined && + (fromYUntyped(decl.initializer) || fromY(decl.initializer)) + ); + } + if (ts.isParameter(decl) && !decl.type) { + const fn = decl.parent; + const call = fn.parent; + return ( + (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && + ts.isCallExpression(call) && + ts.isPropertyAccessExpression(call.expression) && + fromY(call.expression.expression) + ); + } + return false; + }; + const uses = new Map>(); + const add = (key: string, file: string) => { + const where = uses.get(key) || new Set(); + where.add(path.relative(SRC_DIR, file)); + uses.set(key, where); + }; + for (const file of files) { + const sourceFile = program.getSourceFile(file); + if (sourceFile === undefined) { + throw new Error(`not in the program: ${file}`); + } + const visit = (n: ts.Node) => { + let receiver: ts.Expression | undefined; + let nameNode: ts.Node | undefined; + let name = ''; + if (ts.isPropertyAccessExpression(n)) { + receiver = n.expression; + nameNode = n.name; + name = n.name.text; + } else if ( + ts.isElementAccessExpression(n) && + ts.isStringLiteral(n.argumentExpression) + ) { + receiver = n.expression; + nameNode = n.argumentExpression; + name = n.argumentExpression.text; + } + if ( + receiver && + checker.getTypeAtLocation(receiver).flags & ts.TypeFlags.Any && + fromYUntyped(receiver) + ) { + // A value from @y/y whose type does not resolve (`any`): its + // members would escape the inventory. Type it. + const {line} = sourceFile.getLineAndCharacterOfPosition( + receiver.getStart(), + ); + add( + `untyped @y/y value '${receiver.getText()}' at line ${line + 1}`, + file, + ); + } + if (receiver && nameNode) { + const of = owners( + checker.getNonNullableType(checker.getTypeAtLocation(receiver)), + ); + const symbol = checker.getSymbolAtLocation(nameNode); + const decl = symbol && symbol.declarations && symbol.declarations[0]; + if (of.length > 0) { + of.forEach(o => add(`${o}#${name}`, file)); + } else if (decl && inY(decl)) { + add(`${nameOf(decl.parent)}#${name}`, file); + } + const call = n.parent; + if ( + of.length > 0 && + (name === 'on' || name === 'off' || name === 'once') && + ts.isCallExpression(call) && + call.arguments.length > 0 && + ts.isStringLiteral(call.arguments[0]) + ) { + const event = (call.arguments[0] as ts.StringLiteral).text; + of.forEach(o => add(`${o}@${event}`, file)); + } + } + if (ts.isObjectBindingPattern(n)) { + const of = owners( + checker.getNonNullableType(checker.getTypeAtLocation(n)), + ); + for (const element of n.elements) { + const prop = element.propertyName || element.name; + if (ts.isIdentifier(prop)) { + of.forEach(o => add(`${o}#${prop.text}`, file)); + } + } + } + if (ts.isObjectLiteralExpression(n)) { + const contextual = checker.getContextualType(n); + if (contextual) { + const type = checker.getApparentType( + checker.getNonNullableType(contextual), + ); + for (const p of n.properties) { + if (p.name && ts.isIdentifier(p.name)) { + const prop = checker.getPropertyOfType(type, p.name.text); + const decl = prop && prop.declarations && prop.declarations[0]; + if (decl && inY(decl)) { + add(`${nameOf(decl.parent)}#${p.name.text}`, file); + } + } + } + } + } + if (ts.isNewExpression(n)) { + let symbol = checker.getSymbolAtLocation(n.expression); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(symbol); + } + const decl = symbol && symbol.declarations && symbol.declarations[0]; + if (symbol && decl && inY(decl)) { + add(`new ${symbol.name}`, file); + } + } + if ( + ts.isImportDeclaration(n) && + ts.isStringLiteral(n.moduleSpecifier) && + n.moduleSpecifier.text === '@y/y' && + n.importClause && + !n.importClause.isTypeOnly + ) { + const bindings = n.importClause.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + if (!element.isTypeOnly) { + add( + `import ${(element.propertyName || element.name).text}`, + file, + ); + } + } + } else if (bindings) { + add('import * (a namespace import: name each export)', file); + } + } + ts.forEachChild(n, visit); + }; + visit(sourceFile); + } + return uses; +} + +describe('the contract', () => { + // Type-checks the package's sources: about 1.5 s alone, more under load. + test('lists exactly what the sources use of @y/y', {timeout: 60_000}, () => { + const uses = inventory(); + // `YNode` is the class @y/y exports as `Node`. + const exportName = (name: string) => (name === 'YNode' ? 'Node' : name); + const used = new Set(); + for (const key of uses.keys()) { + if (key.startsWith('import ')) { + used.add(`export ${exportName(key.slice(7))}`); + } else if (key.startsWith('new ')) { + used.add(`export ${exportName(key.slice(4))}`); + } else { + used.add(key); + } + } + const listed = new Set([ + ...YJS_EXPORTS.map(name => `export ${name}`), + ...Object.entries(yjsMembers()).flatMap(([owner, members]) => + Object.keys(members).map(m => `${owner}#${m}`), + ), + ...Object.entries(YJS_EVENTS).flatMap(([owner, events]) => + events.map(e => `${owner}@${e}`), + ), + ]); + const where = (key: string) => { + const files = + uses.get(key) || uses.get(key.replace('export ', 'import ')); + return files ? ` (${[...files].join(', ')})` : ''; + }; + // Used but not in the contract: add it to the lists in + // src/internal/YjsContract.ts (its arity for an export), so the runtime + // check probes it. Listed but no longer used: remove it. + expect({ + listedButUnused: [...listed].filter(k => !used.has(k)).sort(), + usedButNotListed: [...used] + .filter(k => !listed.has(k)) + .sort() + .map(k => k + where(k)), + }).toEqual({listedButUnused: [], usedButNotListed: []}); + }); + + test('the installed @y/y passes the probe', () => { + expect(probeYjs()).toEqual([]); + }); + + test('content classes are told apart by identity, not name', () => { + // A minifier can give every class the same name. + const classes = [ + Y.ContentAny, + Y.ContentBinary, + Y.ContentFormat, + Y.ContentString, + Y.ContentType, + ]; + const names = classes.map(klass => + Object.getOwnPropertyDescriptor(klass, 'name'), + ); + const proto = Y.ContentAny.prototype as {getContent?: unknown}; + const getContent = proto.getContent; + classes.forEach(klass => + Object.defineProperty(klass, 'name', {configurable: true, value: 'e'}), + ); + proto.getContent = undefined; + try { + expect(probeYjs()).toEqual(['AbstractContent(e)#getContent']); + } finally { + proto.getContent = getContent; + classes.forEach((klass, i) => + Object.defineProperty(klass, 'name', names[i]!), + ); + } + expect(probeYjs()).toEqual([]); + }); + + test('the check runs in another realm than @y/y', async () => { + // As a VM-pool test runner or an embedder does: @lexical/y evaluated in + // a realm of its own (its own Map, Uint8Array), @y/y loaded by the host. + const esbuild = await import('esbuild'); + const {outputFiles} = await esbuild.build({ + bundle: true, + entryPoints: [path.join(SRC_DIR, 'internal/YjsContract.ts')], + external: ['@y/y'], + format: 'cjs', + platform: 'neutral', + write: false, + }); + const context = vm.createContext({}); + expect(vm.runInContext('Map', context)).not.toBe(Map); + const module = {exports: {} as {probeYjs: () => string[]}}; + vm.runInContext( + `(function (module, exports, require) {${outputFiles[0].text}\n})`, + context, + )(module, module.exports, (id: string) => { + expect(id).toBe('@y/y'); + return Y; + }); + expect(module.exports.probeYjs()).toEqual([]); + }); + + test('every kind of content is checked, attribute values included', () => { + const proto = Y.ContentAny.prototype as {getContent?: unknown}; + const getContent = proto.getContent; + proto.getContent = undefined; + try { + expect(probeYjs()).toEqual([ + expect.stringMatching(/^AbstractContent\(\w*ContentAny\)#getContent$/), + ]); + } finally { + proto.getContent = getContent; + } + expect(probeYjs()).toEqual([]); + }); +}); + +describe('the error', () => { + test('is typed data with a short message that names the fix', () => { + expectTypeOf().toEqualTypeOf<{ + readonly supported: string; + readonly found: readonly string[]; + readonly reason: 'changed-internals' | 'other-copy'; + }>(); + const error = new YUnsupportedYjsError({ + found: ['Item#parentSub'], + reason: 'changed-internals', + supported: SUPPORTED_YJS_VERSION, + }); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('YUnsupportedYjsError'); + expect(error.code).toBe(UNSUPPORTED_YJS_ERROR); + expect(error.message).toBe( + `@lexical/y supports exactly @y/y ${SUPPORTED_YJS_VERSION}, and the @y/y the app loaded differs (Item#parentSub). Install @y/y@${SUPPORTED_YJS_VERSION}, once per app.`, + ); + }); +}); + +type YModule = typeof Y; + +/** + * Load @lexical/y (and its headless entry) afresh against a @y/y changed by + * `change`, as an app that installed another version would. + */ +async function loadWith(change: (y: YModule) => Partial) { + vi.resetModules(); + vi.doMock('@y/y', async importOriginal => { + const y = await importOriginal(); + return {...y, ...change(y)}; + }); + const y = await import('@y/y'); + const lexical = await import('lexical'); + const extension = await import('@lexical/extension'); + const lexicalY = await import('../..'); + const headless = await import('../../headless'); + const contract = await import('../../internal/YjsContract'); + return {contract, extension, headless, lexical, lexicalY, y}; +} + +afterEach(() => { + vi.doUnmock('@y/y'); + vi.resetModules(); +}); + +const config = {schemaVersion: 1}; + +function seeded(): Uint8Array { + // A stored state, made with the installed @y/y before any change. + const doc = new Y.Doc(); + doc.cleanupFormatting = false; + doc.get('root').insert(0, 'stored'); + return Y.encodeStateAsUpdate(doc); +} + +/** A copy of `Base` that keeps its arity (`Function#length`). */ +function sameArity(Base: T, Derived: T): T { + Object.defineProperty(Derived, 'length', {value: Base.length}); + return Derived; +} + +const CHANGES: [string, (y: YModule) => Partial, string[]][] = [ + [ + 'a missing export', + () => ({ + getItemCleanStart: undefined as unknown as YModule['getItemCleanStart'], + }), + ['getItemCleanStart'], + ], + [ + 'an emptied method', + y => ({ + UndoManager: sameArity( + y.UndoManager, + class extends y.UndoManager { + constructor(...args: ConstructorParameters) { + super(...args); + (this as {canRedo?: unknown}).canRedo = undefined; + } + }, + ), + }), + ['UndoManager#canRedo'], + ], + [ + 'a field that is null', + y => ({ + decodeUpdate: ((update: Uint8Array) => ({ + ...y.decodeUpdate(update), + ds: null, + })) as unknown as YModule['decodeUpdate'], + }), + ['decodeUpdate#ds'], + ], + [ + 'a missing member', + y => ({ + Doc: sameArity( + y.Doc, + class extends y.Doc { + constructor(...args: ConstructorParameters) { + super(...args); + delete (this as {_transactionCleanups?: unknown}) + ._transactionCleanups; + } + }, + ), + }), + ['Doc#_transactionCleanups'], + ], + [ + 'a renamed field the probe assigns', + y => ({ + Doc: sameArity( + y.Doc, + class extends y.Doc { + constructor(...args: ConstructorParameters) { + super(...args); + const self = this as { + cleanupFormatting?: boolean; + cleanupFormattingRenamed?: boolean; + }; + self.cleanupFormattingRenamed = self.cleanupFormatting; + delete self.cleanupFormatting; + } + }, + ), + }), + ['Doc#cleanupFormatting'], + ], + [ + 'a missing event', + y => ({ + UndoManager: sameArity( + y.UndoManager, + class extends y.UndoManager { + emit(name: string, args: unknown[]) { + if (name !== 'stack-cleared') { + super.emit(name as 'stack-cleared', args as never); + } + } + }, + ), + }), + ['UndoManager@stack-cleared'], + ], +]; + +describe.each(CHANGES)('with %s', (_, change, found) => { + // Each change is named in `found`; what it then breaks may follow. + const expected = { + data: { + found: expect.arrayContaining(found), + reason: 'changed-internals', + supported: SUPPORTED_YJS_VERSION, + }, + }; + + test('createYDoc throws before it makes or loads a document', async () => { + const {lexicalY} = await loadWith(change); + const state = seeded(); + const before = new Uint8Array(state); + let thrown: unknown; + try { + lexicalY.createYDoc(state); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(lexicalY.YUnsupportedYjsError); + expect(thrown).toMatchObject(expected); + expect(state).toEqual(before); + }); + + test('YBinding and YCollabExtension throw before writing to the document', async () => { + const {extension, lexicalY} = await loadWith(change); + // A document of the installed @y/y holding a stored state, as the + // app's provider would have loaded it: nothing reaches it. + const doc = new Y.Doc(); + doc.cleanupFormatting = false; + Y.applyUpdate(doc, seeded()); + const before = Y.encodeStateAsUpdate(doc); + const shared = doc.share.size; + const ydoc = doc as unknown as ReturnType; + const editor = extension.buildEditorFromExtensions( + extension.defineExtension({name: '@lexical/y/test/yjs-contract'}), + ); + expect(() => new lexicalY.YBinding(editor, ydoc, config)).toThrow( + expect.objectContaining(expected), + ); + let thrown: unknown; + try { + extension.buildEditorFromExtensions( + extension.defineExtension({ + dependencies: [ + extension.configExtension(lexicalY.YCollabExtension, { + doc: ydoc, + schemaVersion: 1, + }), + ], + name: '@lexical/y/test/yjs-contract-extension', + }), + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(lexicalY.YUnsupportedYjsError); + expect(thrown).toMatchObject(expected); + expect(Y.encodeStateAsUpdate(doc)).toEqual(before); + expect(doc.share.size).toBe(shared); + }); + + test('the headless helpers throw before building the editor', async () => { + const {extension, headless, lexical, lexicalY} = await loadWith(change); + const state = seeded(); + const before = new Uint8Array(state); + const $build = () => { + lexical.$getRoot().append(lexical.$createParagraphNode()); + }; + const built = {build: 0, register: 0}; + const counted = { + extensions: [ + extension.defineExtension({ + build: () => { + built.build++; + }, + name: '@lexical/y/test/yjs-contract-counted', + register: () => { + built.register++; + return () => {}; + }, + }), + ], + schemaVersion: 1, + }; + expect(headless.YUnsupportedYjsError).toBe(lexicalY.YUnsupportedYjsError); + for (const call of [ + () => headless.seedYState(counted, $build), + () => headless.readYState(state, counted, () => null), + () => headless.editYState(state, counted, $build, {writer: null}), + ]) { + expect(call).toThrow(lexicalY.YUnsupportedYjsError); + expect(call).toThrow(expect.objectContaining(expected)); + } + expect(state).toEqual(before); + expect(built).toEqual({build: 0, register: 0}); + }); + + test('the check runs once per module', async () => { + const {contract, lexicalY} = await loadWith(change); + expect(contract.getYjsProbeRuns()).toBe(0); + for (let i = 0; i < 3; i++) { + expect(() => lexicalY.createYDoc()).toThrow( + lexicalY.YUnsupportedYjsError, + ); + } + expect(contract.getYjsProbeRuns()).toBe(1); + }); + + test("the error's data cannot change the verdict", async () => { + const {lexicalY} = await loadWith(change); + let first: InstanceType | null = null; + try { + lexicalY.createYDoc(); + } catch (error) { + first = error as InstanceType; + } + expect(first).not.toBe(null); + expect(Object.isFrozen(first!.data)).toBe(true); + expect(() => { + (first!.data.found as string[]).length = 0; + }).toThrow(TypeError); + expect(() => lexicalY.createYDoc()).toThrow( + expect.objectContaining(expected), + ); + }); +}); + +describe('with the supported @y/y', () => { + test('every entry point works, and the check runs once', async () => { + const {contract, extension, headless, lexical, lexicalY} = await loadWith( + () => ({}), + ); + const {$createParagraphNode, $createTextNode, $getRoot} = lexical; + expect(contract.getYjsProbeRuns()).toBe(0); + const state = headless.seedYState(config, () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('hello')), + ); + }); + expect( + headless.readYState(state, config, () => $getRoot().getTextContent()), + ).toBe('hello'); + const {state: next} = headless.editYState( + state, + config, + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('world')), + ); + }, + {writer: null}, + ); + expect( + headless.readYState(next, config, () => $getRoot().getTextContent()), + ).toBe('hello\n\nworld'); + const doc = lexicalY.createYDoc(next); + const editor = extension.buildEditorFromExtensions( + extension.defineExtension({ + dependencies: [ + extension.configExtension(lexicalY.YCollabExtension, { + doc, + schemaVersion: 1, + }), + ], + name: '@lexical/y/test/yjs-contract-ok', + }), + ); + expect(editor.read(() => $getRoot().getTextContent())).toBe( + 'hello\n\nworld', + ); + editor.dispose(); + expect(contract.getYjsProbeRuns()).toBe(1); + }); + + test('a build that changes arities or renames properties consistently passes', async () => { + // Transpilers change Function#length (Babel's default and destructured + // parameters); the check does not compare arities. + const {contract} = await loadWith(y => ({ + Doc: Object.defineProperty(class extends y.Doc {}, 'length', { + value: 1, + }) as unknown as YModule['Doc'], + findIndexSS: ((...args: Parameters) => + y.findIndexSS(...args)) as unknown as YModule['findIndexSS'], + })); + expect(contract.probeYjs()).toEqual([]); + }); + + test('a @y/y whose collections come from another realm passes', async () => { + // As when @y/y is loaded in a VM context: its Maps are not this realm's. + const OtherMap = vm.runInNewContext('Map') as MapConstructor; + const {contract} = await loadWith(y => ({ + Doc: Object.defineProperty( + class extends y.Doc { + constructor(...args: ConstructorParameters) { + super(...args); + this.share = new OtherMap(this.share); + } + }, + 'length', + {value: y.Doc.length}, + ) as unknown as YModule['Doc'], + })); + expect(contract.probeYjs()).toEqual([]); + }); + + test('a document or root from another copy of @y/y is refused', async () => { + const {extension, lexicalY} = await loadWith(() => ({})); + const editor = extension.buildEditorFromExtensions( + extension.defineExtension({name: '@lexical/y/test/yjs-contract-copy'}), + ); + // Stand-ins for another copy's classes: not instances of the ones + // @lexical/y loaded. + const other = {clientID: 1} as unknown as ReturnType< + typeof lexicalY.createYDoc + >; + expect(() => new lexicalY.YBinding(editor, other, config)).toThrow( + expect.objectContaining({ + data: { + found: ['Y.Doc'], + reason: 'other-copy', + supported: SUPPORTED_YJS_VERSION, + }, + message: `@lexical/y: the Y.Doc comes from another copy of @y/y than the one @lexical/y loaded. Install @y/y@${SUPPORTED_YJS_VERSION} once per app and create the document with createYDoc.`, + }), + ); + const doc = lexicalY.createYDoc(); + expect( + () => + new lexicalY.YBinding(editor, doc, { + root: {} as unknown as Y.Node, + schemaVersion: 1, + }), + ).toThrow( + expect.objectContaining({ + data: expect.objectContaining({found: ['Y.Node (the root)']}), + }), + ); + expect(doc.share.size).toBe(0); + }); +}); diff --git a/packages/lexical-y/src/headless.ts b/packages/lexical-y/src/headless.ts index f73d89abf2c..197e7af75ac 100644 --- a/packages/lexical-y/src/headless.ts +++ b/packages/lexical-y/src/headless.ts @@ -16,7 +16,7 @@ import { SKIP_COLLAB_TAG, } from 'lexical'; -import {createYDoc, DEFAULT_ROOT_NAME} from '.'; +import {createYDoc, DEFAULT_ROOT_NAME, YUnsupportedYjsError} from '.'; import { closeHeadlessYEditor, EDITOR_WARNING, @@ -50,6 +50,7 @@ export { FOLLOW_UPS_EXHAUSTED_WARNING, WRITER_NEW_CLIENT_ID_WARNING, YHeadlessError, + YUnsupportedYjsError, }; /** @experimental The result of {@link editYState}. */ diff --git a/packages/lexical-y/src/index.ts b/packages/lexical-y/src/index.ts index 27348c958a0..7818e341ae7 100644 --- a/packages/lexical-y/src/index.ts +++ b/packages/lexical-y/src/index.ts @@ -19,6 +19,7 @@ import {type ClientIDRange} from './internal/ClientID'; import {DEFAULT_ROOT_NAME, LAYOUT_VERSION} from './internal/constants'; import {describeStorage} from './internal/Describe'; import {type StorageKind} from './internal/Schema'; +import {SUPPORTED_YJS_VERSION} from './internal/SupportedYjs'; import { isYWarning, NESTED_FALLBACK_WARNING, @@ -54,6 +55,12 @@ import { } from './internal/Warnings'; import {createYDoc, type YDoc, type YDocOptions} from './internal/YDoc'; import {type YCollabConfig, YCollabExtension} from './internal/YExtension'; +import { + UNSUPPORTED_YJS_ERROR, + type YUnsupportedYjsData, + YUnsupportedYjsError, + type YUnsupportedYjsReason, +} from './internal/YjsContract'; export type { ClientIDRange, @@ -78,6 +85,8 @@ export type { YUnsupportedLayoutData, YUnsupportedSchemaData, YUnsupportedSlotsData, + YUnsupportedYjsData, + YUnsupportedYjsReason, YUnsyncedValueData, YWarning, YWarningCode, @@ -94,13 +103,16 @@ export { RECOVERED_WRITE_WARNING, SELF_CHECK_WARNING, STORAGE_MISMATCH_WARNING, + SUPPORTED_YJS_VERSION, UNKNOWN_NODE_TYPES_WARNING, UNSUPPORTED_CONFIG_WARNING, UNSUPPORTED_EDIT_WARNING, UNSUPPORTED_LAYOUT_WARNING, UNSUPPORTED_SCHEMA_WARNING, UNSUPPORTED_SLOTS_WARNING, + UNSUPPORTED_YJS_ERROR, UNSYNCED_VALUE_WARNING, YBinding, YCollabExtension, + YUnsupportedYjsError, }; diff --git a/packages/lexical-y/src/internal/Binding.ts b/packages/lexical-y/src/internal/Binding.ts index d95606db5fd..7a6b9daee66 100644 --- a/packages/lexical-y/src/internal/Binding.ts +++ b/packages/lexical-y/src/internal/Binding.ts @@ -151,6 +151,7 @@ import { } from './Warnings'; import {$holdsLines, type WritePlan, Writer} from './Write'; import {createYDoc, type YDoc} from './YDoc'; +import {assertOwnYjsDoc, assertSupportedYjs} from './YjsContract'; // YBinding: one editor bound to one shared root. It owns the lifecycle (the // layout gate, pins, initialization, dispose), the local pipeline (onUpdate: @@ -502,7 +503,7 @@ function recordSchema( return; } let wrote = false; - tr.changed.forEach((_subs, type) => { + tr.changed.forEach((_subs: Set, type: YNode) => { wrote = wrote || under(type); }); if (wrote) { @@ -816,7 +817,9 @@ export class YBinding { private readonly afterTransaction: (tr: Transaction) => void; private readonly beforeTransaction: (tr: Transaction) => void; private readonly beforeObserverCalls = (tr: Transaction): void => { - tr.changed.forEach((subs, type) => { + // Typed: @y/y declares `changed` with a YNode it does not import, so + // the parameters would be `any` (and the contract test blind to them). + tr.changed.forEach((subs: Set, type: YNode) => { if (this.state.containers.has(type) || this.isUnderRoot(type)) { type._hasFormatting = false; } @@ -932,6 +935,10 @@ export class YBinding { } constructor(editor: LexicalEditor, doc: YDoc, options: YBindingOptions) { + // Before anything else: the @y/y the app loaded is the one this package + // supports (once per module), and the document and root come from it. + assertSupportedYjs(); + assertOwnYjsDoc(doc); invariant( options != null && isSchemaVersion(options.schemaVersion), "@lexical/y: YBinding needs options.schemaVersion (got %s): start at 1 and increase it whenever a node's stored fields or node types change", @@ -941,6 +948,7 @@ export class YBinding { this._doc = doc; this.options = options; this._root = options.root || doc.get(DEFAULT_ROOT_NAME); + assertOwnYjsDoc(doc, this._root); this.state = new State(editor, doc, this._root); this.afterTransaction = tr => this.guard(() => withPins(this._pins, () => this.onAfterTransaction(tr))); @@ -2484,7 +2492,7 @@ export class YBinding { const attrKeys = new Map>(); // Whether the change brought a schema record (see gateSchema). let schemaChanged = false; - tr.changed.forEach((subs, type) => { + tr.changed.forEach((subs: Set, type: YNode) => { const keys = new Set(); subs.forEach(k => { if (k !== null) { diff --git a/packages/lexical-y/src/internal/HeadlessEditor.ts b/packages/lexical-y/src/internal/HeadlessEditor.ts index fd1312182ac..435ece338e1 100644 --- a/packages/lexical-y/src/internal/HeadlessEditor.ts +++ b/packages/lexical-y/src/internal/HeadlessEditor.ts @@ -382,8 +382,6 @@ export function openHeadlessYEditor( "@lexical/y/headless: the config needs schemaVersion (got %s): start at 1 and increase it whenever a node's stored fields or node types change, as the clients' YCollabExtension does", String(options == null ? undefined : options.schemaVersion), ); - const warnings: LexicalWarning[] = []; - const {editor, report, sink} = buildHeadlessEditor(options, warnings); // No garbage collection, only so that the root check of an edit's update // (editYState) can resolve deleted items: this document is temporary, and // the states the helpers return are garbage collected. The binding keeps @@ -391,8 +389,19 @@ export function openHeadlessYEditor( // written before that. No format cleanup either, as on every document the // binding writes (see createYDoc): loading the state would otherwise // delete format items the clients keep, and those deletions would be in - // the returned state but not in the update. + // the returned state but not in the update. Made before the editor, so + // that createYDoc's check of the app's @y/y refuses before any extension + // is built or registered. const doc = createYDoc(null, {gc: false}); + const warnings: LexicalWarning[] = []; + let built: ReturnType; + try { + built = buildHeadlessEditor(options, warnings); + } catch (error) { + doc.destroy(); + throw error; + } + const {editor, report, sink} = built; const writes: Uint8Array[] = []; const collect = (update: Uint8Array) => { writes.push(new Uint8Array(update)); diff --git a/packages/lexical-y/src/internal/SupportedYjs.ts b/packages/lexical-y/src/internal/SupportedYjs.ts new file mode 100644 index 00000000000..4b3c9357126 --- /dev/null +++ b/packages/lexical-y/src/internal/SupportedYjs.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// Generated from this package's package.json (`peerDependencies['@y/y']`) by +// scripts/updateVersion.mjs (`pnpm run update-version`): do not edit by +// hand. YjsContract.test fails when the two differ. + +/** + * @experimental The one `@y/y` version this `@lexical/y` supports: its exact + * peer dependency. `YUnsupportedYjsError` reports it as `data.supported`. + */ +export const SUPPORTED_YJS_VERSION = '14.0.0-rc.29'; diff --git a/packages/lexical-y/src/internal/YDoc.ts b/packages/lexical-y/src/internal/YDoc.ts index fa05a1a8fd0..f779b9b0b35 100644 --- a/packages/lexical-y/src/internal/YDoc.ts +++ b/packages/lexical-y/src/internal/YDoc.ts @@ -21,6 +21,8 @@ import {applyUpdate, Doc} from '@y/y'; +import {assertSupportedYjs} from './YjsContract'; + /** * The key of {@link YDoc}'s phantom brand. Declared rather than defined: it * exists only in the type system, so nothing is emitted for it. Named so @@ -53,11 +55,14 @@ export type YDocOptions = ConstructorParameters[0]; * there. `state`, a stored state, is applied when given; `options` are those * of `new Y.Doc(options)` (the default document is garbage collected, as * clients store one). Merging updates with `mergeUpdates` needs no document. + * Throws a `YUnsupportedYjsError`, before the document is made, when the + * `@y/y` the app loaded is not the one `@lexical/y` supports. */ export function createYDoc( state?: Uint8Array | null, options?: YDocOptions, ): YDoc { + assertSupportedYjs(); const doc = new Doc(options); doc.cleanupFormatting = false; if (state != null) { diff --git a/packages/lexical-y/src/internal/YjsContract.ts b/packages/lexical-y/src/internal/YjsContract.ts new file mode 100644 index 00000000000..3fe542c4f33 --- /dev/null +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -0,0 +1,631 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + applyUpdate, + ContentAny, + ContentBinary, + ContentFormat, + ContentString, + ContentType, + createID, + createIdSet, + createRelativePositionFromJSON, + decodeUpdate, + Doc, + encodeStateAsUpdate, + encodeStateVector, + findIndexSS, + findRootTypeKey, + type GC, + getItemCleanStart, + type ID, + type IdSet, + Item, + iterateStructsByIdSet, + mergeUpdates, + Node as YNode, + RelativePosition, + relativePositionToJSON, + Skip, + type Transaction, + UndoManager, +} from '@y/y'; + +import {SUPPORTED_YJS_VERSION} from './SupportedYjs'; + +/* + * The Yjs contract: what @lexical/y uses of @y/y, and a check that the @y/y + * the app loaded provides it. + * + * The binding is coupled to one exact @y/y version on purpose: it writes + * through its own cursor on Yjs items and reads struct fields that are not + * public API (see Cursor). A package manager only warns about a peer + * dependency it cannot satisfy (pnpm by default, npm with + * --legacy-peer-deps), so an app can still run another @y/y. @y/y exposes no + * version at run time: no export or constant, and its duplicate-import flag + * (`globalThis['__ $YJS14$ __']`) is only `true`; its `package.json` + * subpath needs a JSON import that not every bundler or runtime supports, + * and synchronously none. The exact version is enforced by the peer + * dependency; at run time the check probes the contract itself, + * once per module, on a scratch document: every export below is a + * function, every member below has the kind of value the binding expects on + * an object of that kind, and every event below fires. Any difference + * refuses the binding with a YUnsupportedYjsError before it writes anything. + * + * Members are checked through property reads typed against @y/y's + * declarations (not by name), so tsc fails on a member the declarations + * drop, and a build that renames properties renames them here as it does in + * the binding. Arities are not compared: transpilers change + * `Function#length` (default and destructured parameters). + * + * The lists are the contract. YjsContract.test derives, with the TypeScript + * checker, everything the package's sources use of @y/y (value imports, + * `new`, members, destructured fields, options and event names) and fails + * when it differs from these lists in either direction: a new use of a Yjs + * export, field, method, option or event lands with its entry here. + */ + +/** The value exports of @y/y the binding uses. */ +export const YJS_EXPORTS = [ + 'ContentAny', + 'ContentBinary', + 'ContentFormat', + 'ContentString', + 'ContentType', + 'Doc', + 'Item', + 'Node', + 'RelativePosition', + 'Skip', + 'UndoManager', + 'applyUpdate', + 'createID', + 'createIdSet', + 'createRelativePositionFromJSON', + 'decodeUpdate', + 'encodeStateAsUpdate', + 'encodeStateVector', + 'findIndexSS', + 'findRootTypeKey', + 'getItemCleanStart', + 'iterateStructsByIdSet', + 'mergeUpdates', + 'relativePositionToJSON', +] as const; + +type StructStore = Doc['store']; +type IdRanges = NonNullable>; +type IdRange = ReturnType[number]; +type StackItem = UndoManager['undoStack'][number]; +type AbstractContent = Item['content']; +type DecodedUpdate = ReturnType; +interface StackItemEvent { + stackItem: StackItem; + origin: unknown; +} + +const isFunction = (value: unknown): boolean => typeof value === 'function'; +const isObject = (value: unknown): boolean => + typeof value === 'object' && value !== null; +/** A field that may hold null (an empty pointer), never undefined. */ +const isSet = (value: unknown): boolean => value !== undefined; +const isNumber = (value: unknown): boolean => typeof value === 'number'; +const isBoolean = (value: unknown): boolean => typeof value === 'boolean'; +/** A Map from any realm (@y/y may run in another one, such as a VM's). */ +const isMap = (value: unknown): boolean => { + try { + Map.prototype.has.call(value, undefined); + return true; + } catch { + return false; + } +}; + +/** + * The members (fields, getters, methods) the binding reads or calls, by the + * name of the @y/y type that declares them, each with the check of its + * value. `DocOpts` and `UndoManagerOptions` are constructor options the + * binding passes (checked on what the constructor kept); `StackItemEvent` + * is the payload of the undo manager's stack events; `decodeUpdate` is that + * function's result. + * + * @__NO_SIDE_EFFECTS__ + */ +export function yjsMembers() { + return { + AbstractContent: { + getContent: (c: AbstractContent) => isFunction(c.getContent), + }, + ContentFormat: { + key: (c: ContentFormat) => typeof c.key === 'string', + value: (c: ContentFormat) => isSet(c.value), + }, + ContentString: { + str: (c: ContentString) => typeof c.str === 'string', + }, + ContentType: { + type: (c: ContentType) => isObject(c.type), + }, + Doc: { + _transaction: (d: Doc) => isSet(d._transaction), + _transactionCleanups: (d: Doc) => Array.isArray(d._transactionCleanups), + cleanupFormatting: (d: Doc) => isBoolean(d.cleanupFormatting), + clientID: (d: Doc) => isNumber(d.clientID), + destroy: (d: Doc) => isFunction(d.destroy), + get: (d: Doc) => isFunction(d.get), + off: (d: Doc) => isFunction(d.off), + on: (d: Doc) => isFunction(d.on), + share: (d: Doc) => isMap(d.share), + store: (d: Doc) => isObject(d.store), + transact: (d: Doc) => isFunction(d.transact), + }, + DocOpts: { + gc: (d: Doc) => d.gc === false, + }, + GC: { + id: (g: GC) => isObject(g.id), + }, + ID: { + client: (id: ID) => isNumber(id.client), + clock: (id: ID) => isNumber(id.clock), + }, + IdRange: { + clock: (r: IdRange) => isNumber(r.clock), + len: (r: IdRange) => isNumber(r.len), + }, + IdRanges: { + getIds: (r: IdRanges) => isFunction(r.getIds), + }, + IdSet: { + add: (s: IdSet) => isFunction(s.add), + clients: (s: IdSet) => isMap(s.clients), + hasId: (s: IdSet) => isFunction(s.hasId), + }, + Item: { + content: (it: Item) => isObject(it.content), + countable: (it: Item) => isBoolean(it.countable), + delete: (it: Item) => isFunction(it.delete), + deleted: (it: Item) => isBoolean(it.deleted), + id: (it: Item) => isObject(it.id), + integrate: (it: Item) => isFunction(it.integrate), + lastId: (it: Item) => isObject(it.lastId), + left: (it: Item) => isSet(it.left), + length: (it: Item) => isNumber(it.length), + origin: (it: Item) => isSet(it.origin), + parent: (it: Item) => isSet(it.parent), + parentSub: (it: Item) => isSet(it.parentSub), + redone: (it: Item) => isSet(it.redone), + right: (it: Item) => isSet(it.right), + rightOrigin: (it: Item) => isSet(it.rightOrigin), + }, + RelativePosition: { + assoc: (p: RelativePosition) => isNumber(p.assoc), + item: (p: RelativePosition) => isSet(p.item), + }, + Skip: { + id: (k: Skip) => isObject(k.id), + }, + StackItem: { + deletes: (s: StackItem) => isObject(s.deletes), + inserts: (s: StackItem) => isObject(s.inserts), + meta: (s: StackItem) => isMap(s.meta), + }, + StackItemEvent: { + origin: (e: StackItemEvent) => isSet(e.origin), + stackItem: (e: StackItemEvent) => isObject(e.stackItem), + }, + StructStore: { + clients: (s: StructStore) => isMap(s.clients), + getClock: (s: StructStore) => isFunction(s.getClock), + getItem: (s: StructStore) => isFunction(s.getItem), + }, + Transaction: { + changed: (tr: Transaction) => isMap(tr.changed), + deleteSet: (tr: Transaction) => isObject(tr.deleteSet), + doc: (tr: Transaction) => isObject(tr.doc), + insertSet: (tr: Transaction) => isObject(tr.insertSet), + origin: (tr: Transaction) => isSet(tr.origin), + }, + UndoManager: { + canRedo: (u: UndoManager) => isFunction(u.canRedo), + canUndo: (u: UndoManager) => isFunction(u.canUndo), + clear: (u: UndoManager) => isFunction(u.clear), + destroy: (u: UndoManager) => isFunction(u.destroy), + on: (u: UndoManager) => isFunction(u.on), + redo: (u: UndoManager) => isFunction(u.redo), + redoStack: (u: UndoManager) => Array.isArray(u.redoStack), + undo: (u: UndoManager) => isFunction(u.undo), + undoStack: (u: UndoManager) => Array.isArray(u.undoStack), + }, + UndoManagerOptions: { + captureTimeout: (u: UndoManager) => u.captureTimeout === 0, + deleteFilter: (u: UndoManager) => u.deleteFilter === PROBE_DELETE_FILTER, + trackedOrigins: (u: UndoManager) => u.trackedOrigins.has(PROBE_ORIGIN), + }, + YNode: { + _hasFormatting: (n: YNode) => isBoolean(n._hasFormatting), + _item: (n: YNode) => isSet(n._item), + _map: (n: YNode) => isMap(n._map), + _searchMarker: (n: YNode) => isSet(n._searchMarker), + _start: (n: YNode) => isSet(n._start), + deleteAttr: (n: YNode) => isFunction(n.deleteAttr), + doc: (n: YNode) => isSet(n.doc), + getAttr: (n: YNode) => isFunction(n.getAttr), + getAttrs: (n: YNode) => isFunction(n.getAttrs), + length: (n: YNode) => isNumber(n.length), + name: (n: YNode) => isSet(n.name), + parent: (n: YNode) => isSet(n.parent), + setAttr: (n: YNode) => isFunction(n.setAttr), + }, + decodeUpdate: { + ds: (u: DecodedUpdate) => isObject(u.ds), + structs: (u: DecodedUpdate) => Array.isArray(u.structs), + }, + }; +} + +const PROBE_GUID = '@lexical/y/yjs-probe'; +/** The undo manager options the probe passes, checked on what it kept. */ +const PROBE_ORIGIN = {}; +const PROBE_DELETE_FILTER = () => true; + +/** The events the binding listens to, by emitter. */ +export const YJS_EVENTS = { + Doc: [ + 'afterAllTransactions', + 'afterTransaction', + 'beforeObserverCalls', + 'beforeTransaction', + 'update', + ], + UndoManager: ['stack-cleared', 'stack-item-added', 'stack-item-popped'], +} as const; + +/** @experimental The code of {@link YUnsupportedYjsError}. */ +export const UNSUPPORTED_YJS_ERROR = '@lexical/y/unsupported-yjs'; + +/** + * @experimental Why {@link YUnsupportedYjsError} refused: + * - `'changed-internals'`: the `@y/y` the app loaded is not the supported + * version: exports, members or events the binding uses are missing or + * changed (`data.found` names them). + * - `'other-copy'`: the document comes from another copy of `@y/y` than + * the one `@lexical/y` loaded, so the binding's `instanceof` checks on its + * items would fail. + */ +export type YUnsupportedYjsReason = 'changed-internals' | 'other-copy'; + +/** @experimental The data of {@link YUnsupportedYjsError}. */ +export interface YUnsupportedYjsData { + /** The exact `@y/y` version this `@lexical/y` supports. */ + readonly supported: string; + /** + * What the check found instead: for `'changed-internals'`, each missing + * or changed part of the contract (`'getItemCleanStart'`, + * `'Item#parentSub'`, `'UndoManager@stack-cleared'`); + * for `'other-copy'`, what came from the other copy. + */ + readonly found: readonly string[]; + readonly reason: YUnsupportedYjsReason; +} + +/** + * @experimental Thrown by `createYDoc`, by the `YBinding` constructor (so by + * `YCollabExtension`) and by the headless helpers when the `@y/y` the app + * runs is not the one `@lexical/y` supports, before anything is written. + * Fix: install exactly `@y/y@`, once per app. There is no + * way to turn the check off. + */ +export class YUnsupportedYjsError extends Error { + readonly code: typeof UNSUPPORTED_YJS_ERROR; + readonly data: YUnsupportedYjsData; + constructor(data: YUnsupportedYjsData) { + super(unsupportedYjsMessage(data)); + this.name = 'YUnsupportedYjsError'; + this.code = UNSUPPORTED_YJS_ERROR; + this.data = Object.freeze({ + ...data, + found: Object.freeze(data.found.slice()), + }); + } +} + +function unsupportedYjsMessage({ + found, + reason, + supported, +}: YUnsupportedYjsData): string { + if (reason === 'other-copy') { + return `@lexical/y: the ${found.join(' and ')} comes from another copy of @y/y than the one @lexical/y loaded. Install @y/y@${supported} once per app and create the document with createYDoc.`; + } + const shown = found.slice(0, 5).join(', '); + const more = found.length > 5 ? `, and ${found.length - 5} more` : ''; + return `@lexical/y supports exactly @y/y ${supported}, and the @y/y the app loaded differs (${shown}${more}). Install @y/y@${supported}, once per app.`; +} + +/** + * The exports the check uses, named statically (a namespace import read by + * key would keep every export of @y/y in an app's bundle). Only what the + * binding imports: a new named import would fail to link, before this + * check, in native ES modules without it. + * + * @__NO_SIDE_EFFECTS__ + */ +function yjsExports() { + return { + ContentAny, + ContentBinary, + ContentFormat, + ContentString, + ContentType, + Doc, + Item, + Node: YNode, + RelativePosition, + Skip, + UndoManager, + applyUpdate, + createID, + createIdSet, + createRelativePositionFromJSON, + decodeUpdate, + encodeStateAsUpdate, + encodeStateVector, + findIndexSS, + findRootTypeKey, + getItemCleanStart, + iterateStructsByIdSet, + mergeUpdates, + relativePositionToJSON, + }; +} + +export type YjsExports = ReturnType; + +/** + * Probe `y` against the contract: the list of what is missing or changed, + * empty when it matches. Writes only to a scratch document of its own. + */ +export function probeYjs(y: YjsExports = yjsExports()): string[] { + const found: string[] = []; + const exportsOf = y as unknown as Record; + for (const name of YJS_EXPORTS) { + if (typeof exportsOf[name] !== 'function') { + found.push(name); + } + } + if (found.length > 0) { + return found; + } + try { + probeMembers(y, found); + } catch (error) { + // Making the scratch document itself threw. + found.push( + `probe threw: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return found; +} + +/** + * Exercise, on a scratch document, the kinds of objects the binding meets, + * and check every member of {@link yjsMembers} on one of each and every + * event of {@link YJS_EVENTS}. + */ +function probeMembers(y: YjsExports, found: string[]): void { + // Each kind is checked when its first object is made, before the probe + // changes it (an assignment would create a renamed field). + const members = yjsMembers() as unknown as Record< + string, + Record boolean> + >; + const sampled = new Set(); + /** The kinds checked, and for content each class (by identity: a + * minifier can give every class the same name). */ + const checked = new Set(); + /** Check `value` as a `kind`, once per kind (once per `variant` of it, + * named `label`). */ + const sample = ( + kind: string, + value: unknown, + variant: unknown = kind, + label = '', + ) => { + if (checked.has(variant) || typeof value !== 'object' || value === null) { + return; + } + checked.add(variant); + sampled.add(kind); + const variantLabel = label; + for (const [member, check] of Object.entries(members[kind])) { + let ok = false; + try { + ok = check(value); + } catch { + // A getter that throws is a changed member too. + } + if (!ok) { + found.push( + `${kind}${variantLabel === '' ? '' : `(${variantLabel})`}#${member}`, + ); + } + } + }; + const events = new Set(); + // A fixed guid: each document then draws only its client id from the + // app's randomness (a seeded one shifts every later id by each draw). + const doc = new y.Doc({gc: false, guid: PROBE_GUID}); + // A garbage collected document, for a GC struct (the items of a deleted + // node). + const collected = new y.Doc({guid: PROBE_GUID}); + sample('Doc', doc); + sample('DocOpts', doc); + sample('StructStore', doc.store); + const um: {destroy?: () => void} = {}; + let threw: unknown = null; + try { + doc.cleanupFormatting = false; + const root = doc.get('probe'); + for (const event of YJS_EVENTS.Doc) { + doc.on(event, () => events.add(`Doc@${event}`)); + } + const undoManager = new y.UndoManager(root, { + captureTimeout: 0, + deleteFilter: PROBE_DELETE_FILTER, + trackedOrigins: new Set([PROBE_ORIGIN]), + }); + um.destroy = () => undoManager.destroy(); + sample('UndoManager', undoManager); + sample('UndoManagerOptions', undoManager); + for (const event of YJS_EVENTS.UndoManager) { + undoManager.on(event, (payload: unknown) => { + events.add(`UndoManager@${event}`); + if (event === 'stack-item-added') { + sample('StackItemEvent', payload); + sample('StackItem', (payload as {stackItem?: unknown}).stackItem); + } + }); + } + doc.transact(tr => { + sample('Transaction', tr); + root.insert(0, 'ab'); + root.insert(2, [new y.Node('probe')]); + root.format(0, 1, {probe: true}); + root.setAttr('probe', 1); + // Bytes made by @y/y, which may run in another realm (it switches on + // the value's constructor). + root.setAttr('probeBinary', y.encodeStateAsUpdate(collected)); + }, PROBE_ORIGIN); + // Every kind of content the binding meets (text, formats, nodes, + // attribute values), each against the abstract content contract. + const contents: unknown[] = []; + for (let it = root._start; it !== null; it = it.right) { + sample('Item', it); + sample('ID', it.id); + contents.push(it.content); + if (it.content instanceof y.ContentString) { + sample('ContentString', it.content); + } else if (it.content instanceof y.ContentFormat) { + sample('ContentFormat', it.content); + } else if (it.content instanceof y.ContentType) { + sample('ContentType', it.content); + sample('YNode', it.content.type); + } + } + root._map.forEach(item => contents.push(item.content)); + for (const content of contents) { + const klass = (content as {constructor: {name: string}}).constructor; + sample('AbstractContent', content, klass, klass.name || '?'); + } + const ids = y.createIdSet(); + ids.add(1, 0, 2); + sample('IdSet', ids); + const ranges = ids.clients.get(1); + sample('IdRanges', ranges); + sample('IdRange', ranges && ranges.getIds()[0]); + const node = new y.Node('probe'); + collected.transact(() => collected.get('probe').insert(0, [node])); + collected.transact(() => node.insert(0, 'ab')); + collected.transact(() => collected.get('probe').delete(0, 1)); + const structs = collected.store.clients.get(collected.clientID) || []; + sample( + 'GC', + structs.find(struct => !(struct instanceof y.Item)), + ); + sample('RelativePosition', new y.RelativePosition(null, null, null, 0)); + sample('Skip', new y.Skip(y.createID(1, 0), 1)); + sample('decodeUpdate', y.decodeUpdate(y.encodeStateAsUpdate(doc))); + undoManager.undo(); + undoManager.clear(); + } catch (error) { + threw = error; + } finally { + try { + if (um.destroy) { + um.destroy(); + } + doc.destroy(); + collected.destroy(); + } catch (error) { + threw = threw || error; + } + } + // After a throw, what was reached is checked (it usually names the + // cause), then the throw itself; otherwise every kind must have been made. + if (threw !== null) { + found.push( + `probe threw: ${threw instanceof Error ? threw.message : String(threw)}`, + ); + return; + } + for (const kind of Object.keys(members)) { + if (!sampled.has(kind)) { + found.push(`${kind} (not made)`); + } + } + for (const [emitter, names] of Object.entries(YJS_EVENTS)) { + for (const name of names) { + if (!events.has(`${emitter}@${name}`)) { + found.push(`${emitter}@${name}`); + } + } + } +} + +/** What the check found, once per module; undefined until it ran. */ +let verdict: readonly string[] | undefined; +/** How many times the check ran (YjsContract.test). */ +let probeRuns = 0; + +/** @internal How many times this module ran the check. */ +export function getYjsProbeRuns(): number { + return probeRuns; +} + +/** + * Throw a {@link YUnsupportedYjsError} unless the @y/y the app loaded + * provides the contract. Probes once per module; later calls reuse the + * verdict. + */ +export function assertSupportedYjs(): void { + if (verdict === undefined) { + probeRuns++; + verdict = Object.freeze(probeYjs()); + } + if (verdict.length > 0) { + // A copy: what a caller does with the error's data cannot change the + // verdict. + throw new YUnsupportedYjsError({ + found: verdict.slice(), + reason: 'changed-internals', + supported: SUPPORTED_YJS_VERSION, + }); + } +} + +/** + * Throw a {@link YUnsupportedYjsError} unless `doc` is a `Y.Doc` of the + * @y/y this module loaded and `root`, when given, a `Y.Node` of it. + */ +export function assertOwnYjsDoc(doc: unknown, root?: unknown): void { + const other: string[] = []; + if (!(doc instanceof Doc)) { + other.push('Y.Doc'); + } else if (root !== undefined && !(root instanceof YNode)) { + other.push('Y.Node (the root)'); + } + if (other.length > 0) { + throw new YUnsupportedYjsError({ + found: other, + reason: 'other-copy', + supported: SUPPORTED_YJS_VERSION, + }); + } +} diff --git a/scripts/updateVersion.mjs b/scripts/updateVersion.mjs index a8bc0a0964b..bab27d3cf3d 100644 --- a/scripts/updateVersion.mjs +++ b/scripts/updateVersion.mjs @@ -60,6 +60,7 @@ function updatePackage(pkg) { */ async function updateVersion() { await regenerateVersionModules(); + await regenerateSupportedYjsVersion(); packagesManager.getPackages().forEach(updatePackage); glob .sync([ @@ -105,6 +106,46 @@ async function regenerateVersionModules() { } } +/** + * The module that carries the one `@y/y` version `@lexical/y` supports, as a + * literal generated from that package's exact `@y/y` peer dependency (its + * runtime check reports it; YjsContract.test fails when the two differ). + */ +const SUPPORTED_YJS_MODULE = 'packages/lexical-y/src/internal/SupportedYjs.ts'; + +/** + * Rewrite the generated `SUPPORTED_YJS_VERSION` literal to `@lexical/y`'s + * `@y/y` peer dependency. + */ +async function regenerateSupportedYjsVersion() { + const modulePath = path.resolve(SUPPORTED_YJS_MODULE); + const packageJsonPath = path.resolve('packages/lexical-y/package.json'); + if (!fs.existsSync(modulePath) || !fs.existsSync(packageJsonPath)) { + return; + } + const {peerDependencies = {}} = fs.readJsonSync(packageJsonPath); + const supported = peerDependencies['@y/y']; + if ( + typeof supported !== 'string' || + !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(supported) + ) { + throw new Error( + `packages/lexical-y/package.json: peerDependencies['@y/y'] must be one exact version (got ${String(supported)})`, + ); + } + const next = fs + .readFileSync(modulePath, 'utf8') + .replace( + /(export const SUPPORTED_YJS_VERSION = )'[^']*'/, + `$1'${supported}'`, + ); + const prettierConfig = (await prettier.resolveConfig(modulePath)) || {}; + fs.writeFileSync( + modulePath, + await prettier.format(next, {...prettierConfig, filepath: modulePath}), + ); +} + /** * Return true if any non-test source file in the package imports from * `@lexical/internal/...`, meaning the package needs it as a runtime