From 7d3b658f69b0f63fb203027953648a68a5ccb39c Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 14:07:35 -0400 Subject: [PATCH 1/7] [lexical-y][lexical-website] Feature: Refuse to run against a Yjs other than the one @lexical/y supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `@lexical/y` is coupled to one exact Yjs version on purpose (the register's D13/D14): it writes through its own cursor on Yjs items and reads struct fields that are not public API. `package.json` already pins `@y/y` `14.0.0-rc.29` exactly as a peer dependency, but a package manager only warns about an unmet peer (pnpm by default, npm with `--legacy-peer-deps`), so an app could still run another `@y/y` (or a second copy of it) and get silent misbehaviour. This adds a fail-closed runtime guard: - **What it checks.** `@y/y` exposes no version at run time (no export, constant or `Doc` field; its `package.json` subpath is not importable in every bundler and runtime), so the guard checks the contract itself. `src/internal/YjsContract.ts` lists every `@y/y` export the binding uses with its arity in the supported version (23), every member it reads or calls by declaring type (82, `Item#parentSub`, `YNode#_start`, `Doc#_transactionCleanups`, `UndoManagerOptions#deleteFilter`, ...) and every event it listens to (5). `probeYjs` checks them on a scratch document: one of each kind of object, every member present, every event firing. - **When.** Once per module (a module-level, frozen verdict; never per update or per document), from `createYDoc` (before the document is made or the state is applied), the `YBinding` constructor (so `YCollabExtension`, whose `build` makes the binding before any extension registers), and through `createYDoc` every headless helper, which now makes its document before it builds the headless editor so that a refusal builds and registers no extension. The probe takes about 0.1 ms. The `YBinding` constructor also refuses, on every call, a `Y.Doc` or root that is not an instance of the `@y/y` it loaded (a second copy, whose items fail the binding's `instanceof` checks). - **The error.** `YUnsupportedYjsError` (`code: UNSUPPORTED_YJS_ERROR`, `'@lexical/y/unsupported-yjs'`) with `data: {supported, found, reason}`, `reason` `'changed-internals'` or `'other-copy'`, and a short message that names the fix: `@lexical/y supports exactly @y/y 14.0.0-rc.29, and the @y/y the app loaded differs (getItemCleanStart). Install @y/y@14.0.0-rc.29, once per app.` Exported from `@lexical/y` and `@lexical/y/headless`, typed in TypeScript and Flow. Not an `invariant`, so production builds keep the message and data. - **The supported version.** `SUPPORTED_YJS_VERSION` (exported) lives in `src/internal/SupportedYjs.ts`, a literal generated from the package's `peerDependencies['@y/y']` by `scripts/updateVersion.mjs` (`pnpm run update-version`), like the `LEXICAL_VERSION` fallback literal; a test fails when it differs from the peer pin, the dev dependency or the installed `@y/y`. A build-time replacement would not reach apps that compile the `source` export condition. - **Drift.** `YjsContract.test.ts` derives, with the TypeScript checker, everything the package's sources use of `@y/y` (value imports, `new`, member reads and calls on values whose type `@y/y` declares, inherited `lib0` members included, destructured fields, option properties and `on`/`off`/`once` event names) and fails when it differs from the lists in either direction, naming the file. A new use of a Yjs internal lands with its probe. - **No bypass.** There is no option to turn the check off: bumping `@y/y` for Lexical's own upgrade testing means updating the pin and the lists, which the drift test drives. Research notes. Yjs detects a duplicate import with a global flag (`globalThis['__ $YJS14$ __']`) and a `console.error`; it says nothing about versions. y-prosemirror (`yjs ^13.5.38`), y-codemirror.next (`^13.5.6`), `@y/prosemirror` (`^14.0.0-4`), Tiptap's collaboration extension (`^13`) and `@lexical/yjs` (`>=13.5.37`) declare caret or open peer ranges and do no runtime check, although y-prosemirror also reads internals (`_item`, `store.clients`). Known limits: - A different `@y/y` that provides the same exports, arities, members and events passes. Nothing at run time can tell it apart, so the exact pin and the docs still say to install the named version. - A provider that bundles its own copy of Yjs and applies updates to the app's document with it (the `@y/websocket` `latest` case `y.md` already warns about) creates items of the other copy's classes after the binding started; checking that would mean looking at updates, which this guard never does. `y.md` keeps its `npm ls` advice for it. Docs: `y.md` Install says `@lexical/y` supports exactly one Yjs version, what each reason means and the fix; the README has the same note and a "Bumping `@y/y`" checklist (update the pin, run `update-version`, the contract test, the equivalence test, the catalog matrix and the long fuzz). ## Changeset `.changeset/lexical-y-yjs-version-guard.md`: `[feature]`, `patch` (non-breaking: a matching install sees no change). ## Test plan ### Before The new tests against the sources without the two guard calls (`createYDoc` and the `YBinding` constructor): ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts × createYDoc throws before it makes or loads a document × YBinding and YCollabExtension throw before writing to the document × the headless helpers throw × the check runs once per module ... (each of the 4 changed-Yjs cases) × every entry point works, and the check runs once × a document or root from another copy of @y/y is refused Tests 18 failed | 5 passed (23) ``` The drift test, with a use of two `Doc` fields added to a source file and not to the contract: ``` - "usedButNotListed": [], + "usedButNotListed": [ + "Doc#gc (internal/ClientID.ts)", + "Doc#isLoaded (internal/ClientID.ts)", + ], ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts packages/lexical-y/src/__tests__/unit/Source.test.ts Test Files 2 passed (2) Tests 26 passed (26) $ pnpm exec vitest run --project unit packages/lexical-y Test Files 170 passed | 2 skipped (172) Tests 7705 passed | 46 expected fail | 4 skipped (7755) $ pnpm exec vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts ✓ @lexical/y (packages/lexical-y/src/index.ts) Tests 376 passed (376) $ pnpm run ci-check Changeset check passed: 36 pending changeset(s) valid. Found 0 errors (flow) (tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, prettier, lint: clean) $ node scripts/updateVersion.mjs # literal set to '0.0.0-stale' first export const SUPPORTED_YJS_VERSION = '14.0.0-rc.29'; # regenerated; idempotent otherwise ``` Cases covered by `YjsContract.test.ts`: the supported version equals the peer pin, the dev dependency and the installed `@y/y`; the contract equals the derived uses; the arities equal the installed exports; the installed `@y/y` passes; and, with `@y/y` mocked to a missing export, a changed arity, a missing member and a missing event, `createYDoc` (with and without a state), `YBinding`, `YCollabExtension`, `seedYState`, `readYState` and `editYState` each throw the typed error with `data`, the document's state and shared types are unchanged, the input state is untouched, the headless helpers build and register no extension (6 builds and 6 registrations before the headless reorder), the probe runs once for repeated calls, and the error's frozen data cannot change the verdict; with the supported `@y/y` every entry point works and the probe runs once; a document or root from another copy is refused. Flow: `flow/__flowtests__/unsupportedYjs.js.flow` (exhaustive reasons, the headless re-export is the same class). --- .changeset/lexical-y-yjs-version-guard.md | 20 + .../lexical-website/docs/collaboration/y.md | 23 +- packages/lexical-y/README.md | 23 +- packages/lexical-y/flow/LexicalY.js.flow | 22 + .../lexical-y/flow/LexicalYHeadless.js.flow | 4 + .../flow/__flowtests__/unsupportedYjs.js.flow | 54 ++ .../src/__tests__/unit/Source.test.ts | 4 + .../src/__tests__/unit/YjsContract.test.ts | 630 ++++++++++++++++++ packages/lexical-y/src/headless.ts | 3 +- packages/lexical-y/src/index.ts | 12 + packages/lexical-y/src/internal/Binding.ts | 6 + .../lexical-y/src/internal/HeadlessEditor.ts | 15 +- .../lexical-y/src/internal/SupportedYjs.ts | 17 + packages/lexical-y/src/internal/YDoc.ts | 5 + .../lexical-y/src/internal/YjsContract.ts | 489 ++++++++++++++ scripts/updateVersion.mjs | 41 ++ 16 files changed, 1360 insertions(+), 8 deletions(-) create mode 100644 .changeset/lexical-y-yjs-version-guard.md create mode 100644 packages/lexical-y/flow/__flowtests__/unsupportedYjs.js.flow create mode 100644 packages/lexical-y/src/__tests__/unit/YjsContract.test.ts create mode 100644 packages/lexical-y/src/internal/SupportedYjs.ts create mode 100644 packages/lexical-y/src/internal/YjsContract.ts diff --git a/.changeset/lexical-y-yjs-version-guard.md b/.changeset/lexical-y-yjs-version-guard.md new file mode 100644 index 00000000000..6f3b4d578b6 --- /dev/null +++ b/.changeset/lexical-y-yjs-version-guard.md @@ -0,0 +1,20 @@ +--- +'@lexical/y': patch +--- + +[feature] Refuse to run against a Yjs other than the one `@lexical/y` supports, 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`. 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 internals 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'`. + +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 204b6495a26..74bf2ecee27 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -48,8 +48,27 @@ 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 binding writes +through Yjs internals, so `createYDoc`, `YBinding` (and so +`YCollabExtension`) and the headless helpers check, once per app, that the +`@y/y` they loaded provides what the binding uses, and throw a +`YUnsupportedYjsError` before anything is written when it does not: + +- `data.reason: 'changed-internals'`: the app runs another `@y/y` (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`. + +The fix for both 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. A different `@y/y` that +happens to provide the same internals passes it, so pin the version all the +same. + 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`, diff --git a/packages/lexical-y/README.md b/packages/lexical-y/README.md index 50a2068d2c7..24e9b328cde 100644 --- a/packages/lexical-y/README.md +++ b/packages/lexical-y/README.md @@ -13,8 +13,11 @@ 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 +`@y/y` is a peer dependency pinned to the one release candidate this package +supports: install exactly that version, once. `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. Its 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 @@ -103,6 +106,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 arities, 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 741ca7e0c85..295fd6dae05 100644 --- a/packages/lexical-y/flow/LexicalY.js.flow +++ b/packages/lexical-y/flow/LexicalY.js.flow @@ -185,6 +185,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 b15eff3b0a3..86eafd61440 100644 --- a/packages/lexical-y/src/__tests__/unit/Source.test.ts +++ b/packages/lexical-y/src/__tests__/unit/Source.test.ts @@ -67,15 +67,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', @@ -85,6 +88,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..6ac39694467 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts @@ -0,0 +1,630 @@ +/** + * 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 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, + YJS_MEMBERS, +} 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.]+)?$/); + }); +}); + +/** + * 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 program = ts.createProgram(files, {...parsed.options, noEmit: true}); + const checker = program.getTypeChecker(); + const yDir = fs.realpathSync(path.join(PACKAGE_DIR, 'node_modules/@y/y')); + 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('.') || '?'; + }; + const owners = (type: ts.Type): string[] => { + const out: string[] = []; + for (const t of type.isUnion() ? type.types : [type]) { + const symbol = t.aliasSymbol || t.getSymbol(); + const decl = symbol && symbol.declarations && symbol.declarations[0]; + if (symbol && decl && inY(decl)) { + out.push( + symbol.name === '__type' || symbol.name === '__object' + ? nameOf(decl) + : symbol.name, + ); + } + } + return out; + }; + 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 && 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([ + ...Object.keys(YJS_EXPORTS).map(name => `export ${name}`), + ...Object.entries(YJS_MEMBERS).flatMap(([owner, members]) => + 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 export arities are those of the installed @y/y', () => { + const installed = Y as unknown as Record; + for (const [name, arity] of Object.entries(YJS_EXPORTS)) { + expect([name, installed[name].length]).toEqual([name, arity]); + } + }); + + test('the installed @y/y passes the probe', () => { + 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'], + ], + [ + 'a changed arity', + y => ({ + // Same behaviour, one parameter fewer declared. + findIndexSS: ((structs: Parameters[0]) => + y.findIndexSS(structs, 0)) as unknown as YModule['findIndexSS'], + }), + ['findIndexSS/1 (expected 2)'], + ], + [ + '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 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 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 9e3eb784e9a..771337fddea 100644 --- a/packages/lexical-y/src/internal/Binding.ts +++ b/packages/lexical-y/src/internal/Binding.ts @@ -145,6 +145,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: @@ -922,6 +923,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", @@ -931,6 +936,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))); diff --git a/packages/lexical-y/src/internal/HeadlessEditor.ts b/packages/lexical-y/src/internal/HeadlessEditor.ts index a9c7ecfbad4..f94d3e88bf2 100644 --- a/packages/lexical-y/src/internal/HeadlessEditor.ts +++ b/packages/lexical-y/src/internal/HeadlessEditor.ts @@ -372,8 +372,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 @@ -381,8 +379,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..35fa1b7bcbb --- /dev/null +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -0,0 +1,489 @@ +/** + * 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, + findIndexSS, + findRootTypeKey, + GC, + getItemCleanStart, + Item, + iterateStructsByIdSet, + mergeUpdates, + Node as YNode, + RelativePosition, + relativePositionToJSON, + Skip, + 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 (its `package.json` subpath is not something every + * bundler or runtime can import), so the check probes the contract itself, + * once per module, on a scratch document: every export below with the + * arity it has in the supported version, every member below on an object + * of that kind, and every event below firing. Any difference refuses the + * binding with a YUnsupportedYjsError before it writes anything. + * + * 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, with their arity + * (`Function#length`) in the supported version: a changed parameter list + * is a changed contract. + */ +export const YJS_EXPORTS = { + ContentAny: 1, + ContentBinary: 1, + ContentFormat: 2, + ContentString: 1, + ContentType: 1, + Doc: 0, + Item: 8, + Node: 0, + RelativePosition: 3, + Skip: 0, + UndoManager: 1, + applyUpdate: 3, + createID: 2, + createIdSet: 0, + createRelativePositionFromJSON: 1, + decodeUpdate: 1, + encodeStateAsUpdate: 2, + findIndexSS: 2, + findRootTypeKey: 1, + getItemCleanStart: 2, + iterateStructsByIdSet: 3, + mergeUpdates: 1, + relativePositionToJSON: 1, +} as const; + +/** + * The members (fields, getters, methods) the binding reads or calls, by the + * name of the @y/y type that declares them. `DocOpts` and + * `UndoManagerOptions` are constructor options the binding passes; + * `StackItemEvent` is the payload of the undo manager's stack events; + * `decodeUpdate` is that function's result. + */ +export const YJS_MEMBERS = { + AbstractContent: ['getContent'], + ContentFormat: ['key', 'value'], + ContentString: ['str'], + ContentType: ['type'], + Doc: [ + '_transaction', + '_transactionCleanups', + 'cleanupFormatting', + 'clientID', + 'destroy', + 'get', + 'off', + 'on', + 'share', + 'store', + 'transact', + ], + DocOpts: ['gc'], + GC: ['id'], + ID: ['client', 'clock'], + IdRange: ['clock', 'len'], + IdRanges: ['getIds'], + IdSet: ['add', 'clients', 'hasId'], + Item: [ + 'content', + 'countable', + 'delete', + 'deleted', + 'id', + 'integrate', + 'lastId', + 'left', + 'length', + 'origin', + 'parent', + 'parentSub', + 'redone', + 'right', + 'rightOrigin', + ], + RelativePosition: ['assoc', 'item'], + StackItem: ['deletes', 'inserts', 'meta'], + StackItemEvent: ['origin', 'stackItem'], + StructStore: ['clients', 'getClock', 'getItem'], + Transaction: ['changed', 'deleteSet', 'doc', 'insertSet', 'origin'], + UndoManager: [ + 'canRedo', + 'canUndo', + 'clear', + 'destroy', + 'on', + 'redo', + 'redoStack', + 'undo', + 'undoStack', + ], + UndoManagerOptions: ['captureTimeout', 'deleteFilter', 'trackedOrigins'], + YNode: [ + '_item', + '_map', + '_searchMarker', + '_start', + 'deleteAttr', + 'doc', + 'getAttr', + 'getAttrs', + 'length', + 'name', + 'parent', + 'setAttr', + ], + decodeUpdate: ['ds', 'structs'], +} as const; + +/** The events the binding listens to, by emitter. */ +export const YJS_EVENTS = { + Doc: ['afterAllTransactions', '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'`, `'Item/7 (expected 8)'`, `'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). `GC` is only + * probed, for the `GC` members. + * + * @__NO_SIDE_EFFECTS__ + */ +function yjsExports() { + return { + ContentAny, + ContentBinary, + ContentFormat, + ContentString, + ContentType, + Doc, + GC, + Item, + Node: YNode, + RelativePosition, + Skip, + UndoManager, + applyUpdate, + createID, + createIdSet, + createRelativePositionFromJSON, + decodeUpdate, + encodeStateAsUpdate, + 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 [...Object.keys(YJS_EXPORTS), 'GC']) { + const value = exportsOf[name]; + const arity = (YJS_EXPORTS as Record)[name]; + if (typeof value !== 'function') { + found.push(name); + } else if (arity !== undefined && value.length !== arity) { + found.push(`${name}/${value.length} (expected ${arity})`); + } + } + 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 YJS_MEMBERS} on one of each and every + * event of {@link YJS_EVENTS}. + */ +function probeMembers(y: YjsExports, found: string[]): void { + const samples = new Map(); + const sample = (kind: string, value: unknown) => { + if (!samples.has(kind) && typeof value === 'object' && value !== null) { + samples.set(kind, value); + } + }; + const events = new Set(); + const doc = new y.Doc({gc: false}); + sample('Doc', doc); + sample('StructStore', doc.store); + const um: {destroy?: () => void} = {}; + let threw: unknown = null; + try { + doc.cleanupFormatting = false; + const root = doc.get('probe'); + const origin = {}; + const deleteFilter = () => true; + const trackedOrigins = new Set([origin]); + for (const event of YJS_EVENTS.Doc) { + doc.on(event, () => events.add(`Doc@${event}`)); + } + const undoManager = new y.UndoManager(root, { + captureTimeout: 0, + deleteFilter, + trackedOrigins, + }); + um.destroy = () => undoManager.destroy(); + sample('UndoManager', 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); + }, origin); + for (let it = root._start; it !== null; it = it.right) { + sample('Item', it); + sample('ID', it.id); + sample('AbstractContent', 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); + } + } + 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]); + sample('GC', new y.GC(y.createID(1, 0), 1)); + sample('RelativePosition', new y.RelativePosition(null, null, null, 0)); + sample('decodeUpdate', y.decodeUpdate(y.encodeStateAsUpdate(doc))); + // Options are checked by what the constructor kept of them. + if (doc.gc === false) { + sample('DocOpts', {gc: false}); + } + const kept = undoManager as unknown as Record; + if ( + kept.captureTimeout === 0 && + kept.deleteFilter === deleteFilter && + kept.trackedOrigins === trackedOrigins + ) { + sample('UndoManagerOptions', { + captureTimeout: 0, + deleteFilter, + trackedOrigins, + }); + } + undoManager.undo(); + undoManager.clear(); + } catch (error) { + threw = error; + } finally { + try { + if (um.destroy) { + um.destroy(); + } + doc.destroy(); + } catch (error) { + threw = threw || error; + } + } + // After a throw, only what was reached is checked (it usually names the + // cause), then the throw itself. + for (const [kind, members] of Object.entries(YJS_MEMBERS)) { + const value = samples.get(kind); + if (value === undefined && threw !== null) { + continue; + } + for (const member of members) { + if (value === undefined || !(member in value)) { + found.push(`${kind}#${member}`); + } + } + } + if (threw !== null) { + found.push( + `probe threw: ${threw instanceof Error ? threw.message : String(threw)}`, + ); + return; + } + 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 From cd5da790146e8a758e3105916841d8a578ca51b8 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 14:15:18 -0400 Subject: [PATCH 2/7] [lexical-y] Feature: Check Yjs members by typed reads, not names or arities; cover YDoc receivers and untyped callbacks ## Description Review follow-ups on the Yjs guard: - **No arity comparison.** Transpilers change `Function#length` (Babel's default and destructured parameters gave `Doc/1 (expected 0)`, `UndoManager/2 (expected 1)` and two more on the supported `@y/y`), which would refuse a working install. Exports are checked as functions. - **Members are typed property reads with a value check** (`yjsMembers()`: `(d: Doc) => Array.isArray(d._transactionCleanups)`, `(u: UndoManager) => isFunction(u.canRedo)`, `(u) => isObject(u.ds)`) instead of `name in object`: tsc fails on a member `@y/y`'s declarations drop, a build that renames properties renames them here as in the binding, and an emptied method or a null field is caught (both passed `in`). - **The drift test sees more uses.** Receivers whose type extends a `@y/y` type (`YDoc extends Doc`), intersections and type parameters resolve to the `@y/y` type, which adds three `Doc` events the binding listens to (`beforeObserverCalls`, `beforeTransaction`, `afterTransaction`). An untyped (`any`) parameter of a callback given to a method of a `@y/y` value fails the test: `tr.changed.forEach((subs, type) => ...)` was one (`@y/y` declares `changed` with a `YNode` it does not import), hiding `YNode#_hasFormatting`; the three such callbacks are now typed. ## Test plan ### Before ``` - usedButNotListed: [] + "untyped parameter 'type' at line 2442 (internal/Binding.ts)", ... ``` (with the owner fix and the new events in the contract, before typing the callbacks). On the previous revision, a Babel-transpiled `@y/y` was refused with `Doc/1 (expected 0), Node/1 (expected 0), RelativePosition/4 (expected 3), UndoManager/2 (expected 1)`, and `UndoManager.prototype.canRedo = undefined` or a `decodeUpdate` returning `ds: null` passed the probe. ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts packages/lexical-y/src/__tests__/unit/Source.test.ts Tests 35 passed (35) ``` New cases: an emptied method and a null field are refused; a build that changes arities passes. --- packages/lexical-y/README.md | 2 +- .../src/__tests__/unit/YjsContract.test.ts | 140 +++++-- packages/lexical-y/src/internal/Binding.ts | 8 +- .../lexical-y/src/internal/YjsContract.ts | 374 +++++++++++------- 4 files changed, 346 insertions(+), 178 deletions(-) diff --git a/packages/lexical-y/README.md b/packages/lexical-y/README.md index 24e9b328cde..5a74904af68 100644 --- a/packages/lexical-y/README.md +++ b/packages/lexical-y/README.md @@ -114,7 +114,7 @@ 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 arities, members and events in +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 diff --git a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts index 6ac39694467..cf048b3448e 100644 --- a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts +++ b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts @@ -31,7 +31,7 @@ import { probeYjs, YJS_EVENTS, YJS_EXPORTS, - YJS_MEMBERS, + yjsMembers, } from '../../internal/YjsContract'; const PACKAGE_DIR = path.resolve(__dirname, '../../..'); @@ -118,20 +118,53 @@ function inventory(): Map> { } return parts.join('.') || '?'; }; - const owners = (type: ts.Type): string[] => { - const out: string[] = []; - for (const t of type.isUnion() ? type.types : [type]) { - const symbol = t.aliasSymbol || t.getSymbol(); + // 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]; - if (symbol && decl && inY(decl)) { - out.push( - symbol.name === '__type' || symbol.name === '__object' - ? nameOf(decl) - : symbol.name, - ); - } + return (decl !== undefined && inY(decl)) || fromY(node.expression); } - return out; + return false; }; const uses = new Map>(); const add = (key: string, file: string) => { @@ -160,6 +193,35 @@ function inventory(): Map> { nameNode = n.argumentExpression; name = n.argumentExpression.text; } + if ( + receiver && + ts.isIdentifier(receiver) && + checker.getTypeAtLocation(receiver).flags & ts.TypeFlags.Any + ) { + // An untyped parameter of a callback given to a method of a value + // from @y/y (`tr.changed.forEach((subs, type) => ...)`, whose + // element type @y/y does not resolve): its members would escape the + // inventory. Type the parameter. + const symbol = checker.getSymbolAtLocation(receiver); + const decl = symbol && symbol.valueDeclaration; + const fn = decl && ts.isParameter(decl) ? decl.parent : undefined; + const call = fn && fn.parent; + if ( + decl && + !(decl as ts.ParameterDeclaration).type && + fn && + (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && + call && + ts.isCallExpression(call) && + ts.isPropertyAccessExpression(call.expression) && + fromY(call.expression.expression) + ) { + const {line} = sourceFile.getLineAndCharacterOfPosition( + receiver.getStart(), + ); + add(`untyped parameter '${receiver.text}' at line ${line + 1}`, file); + } + } if (receiver && nameNode) { const of = owners( checker.getNonNullableType(checker.getTypeAtLocation(receiver)), @@ -266,9 +328,9 @@ describe('the contract', () => { } } const listed = new Set([ - ...Object.keys(YJS_EXPORTS).map(name => `export ${name}`), - ...Object.entries(YJS_MEMBERS).flatMap(([owner, members]) => - members.map(m => `${owner}#${m}`), + ...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}`), @@ -291,13 +353,6 @@ describe('the contract', () => { }).toEqual({listedButUnused: [], usedButNotListed: []}); }); - test('the export arities are those of the installed @y/y', () => { - const installed = Y as unknown as Record; - for (const [name, arity] of Object.entries(YJS_EXPORTS)) { - expect([name, installed[name].length]).toEqual([name, arity]); - } - }); - test('the installed @y/y passes the probe', () => { expect(probeYjs()).toEqual([]); }); @@ -375,13 +430,29 @@ const CHANGES: [string, (y: YModule) => Partial, string[]][] = [ ['getItemCleanStart'], ], [ - 'a changed arity', + 'an emptied method', y => ({ - // Same behaviour, one parameter fewer declared. - findIndexSS: ((structs: Parameters[0]) => - y.findIndexSS(structs, 0)) as unknown as YModule['findIndexSS'], + 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'], }), - ['findIndexSS/1 (expected 2)'], + ['decodeUpdate#ds'], ], [ 'a missing member', @@ -593,6 +664,19 @@ describe('with the supported @y/y', () => { 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 document or root from another copy of @y/y is refused', async () => { const {extension, lexicalY} = await loadWith(() => ({})); const editor = extension.buildEditorFromExtensions( diff --git a/packages/lexical-y/src/internal/Binding.ts b/packages/lexical-y/src/internal/Binding.ts index 771337fddea..61d3b5415ec 100644 --- a/packages/lexical-y/src/internal/Binding.ts +++ b/packages/lexical-y/src/internal/Binding.ts @@ -493,7 +493,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) { @@ -807,7 +807,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; } @@ -2409,7 +2411,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/YjsContract.ts b/packages/lexical-y/src/internal/YjsContract.ts index 35fa1b7bcbb..6bce5c86576 100644 --- a/packages/lexical-y/src/internal/YjsContract.ts +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -23,6 +23,8 @@ import { findRootTypeKey, GC, getItemCleanStart, + type ID, + type IdSet, Item, iterateStructsByIdSet, mergeUpdates, @@ -30,6 +32,7 @@ import { RelativePosition, relativePositionToJSON, Skip, + type Transaction, UndoManager, } from '@y/y'; @@ -46,10 +49,16 @@ import {SUPPORTED_YJS_VERSION} from './SupportedYjs'; * --legacy-peer-deps), so an app can still run another @y/y. @y/y exposes no * version at run time (its `package.json` subpath is not something every * bundler or runtime can import), so the check probes the contract itself, - * once per module, on a scratch document: every export below with the - * arity it has in the supported version, every member below on an object - * of that kind, and every event below firing. Any difference refuses the - * binding with a YUnsupportedYjsError before it writes anything. + * 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, @@ -58,122 +67,205 @@ import {SUPPORTED_YJS_VERSION} from './SupportedYjs'; * export, field, method, option or event lands with its entry here. */ -/** - * The value exports of @y/y the binding uses, with their arity - * (`Function#length`) in the supported version: a changed parameter list - * is a changed contract. - */ -export const YJS_EXPORTS = { - ContentAny: 1, - ContentBinary: 1, - ContentFormat: 2, - ContentString: 1, - ContentType: 1, - Doc: 0, - Item: 8, - Node: 0, - RelativePosition: 3, - Skip: 0, - UndoManager: 1, - applyUpdate: 3, - createID: 2, - createIdSet: 0, - createRelativePositionFromJSON: 1, - decodeUpdate: 1, - encodeStateAsUpdate: 2, - findIndexSS: 2, - findRootTypeKey: 1, - getItemCleanStart: 2, - iterateStructsByIdSet: 3, - mergeUpdates: 1, - relativePositionToJSON: 1, -} as const; +/** 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', + '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'; /** * The members (fields, getters, methods) the binding reads or calls, by the - * name of the @y/y type that declares them. `DocOpts` and - * `UndoManagerOptions` are constructor options the binding passes; - * `StackItemEvent` is the payload of the undo manager's stack events; - * `decodeUpdate` is that function's result. + * 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 const YJS_MEMBERS = { - AbstractContent: ['getContent'], - ContentFormat: ['key', 'value'], - ContentString: ['str'], - ContentType: ['type'], - Doc: [ - '_transaction', - '_transactionCleanups', - 'cleanupFormatting', - 'clientID', - 'destroy', - 'get', - 'off', - 'on', - 'share', - 'store', - 'transact', - ], - DocOpts: ['gc'], - GC: ['id'], - ID: ['client', 'clock'], - IdRange: ['clock', 'len'], - IdRanges: ['getIds'], - IdSet: ['add', 'clients', 'hasId'], - Item: [ - 'content', - 'countable', - 'delete', - 'deleted', - 'id', - 'integrate', - 'lastId', - 'left', - 'length', - 'origin', - 'parent', - 'parentSub', - 'redone', - 'right', - 'rightOrigin', - ], - RelativePosition: ['assoc', 'item'], - StackItem: ['deletes', 'inserts', 'meta'], - StackItemEvent: ['origin', 'stackItem'], - StructStore: ['clients', 'getClock', 'getItem'], - Transaction: ['changed', 'deleteSet', 'doc', 'insertSet', 'origin'], - UndoManager: [ - 'canRedo', - 'canUndo', - 'clear', - 'destroy', - 'on', - 'redo', - 'redoStack', - 'undo', - 'undoStack', - ], - UndoManagerOptions: ['captureTimeout', 'deleteFilter', 'trackedOrigins'], - YNode: [ - '_item', - '_map', - '_searchMarker', - '_start', - 'deleteAttr', - 'doc', - 'getAttr', - 'getAttrs', - 'length', - 'name', - 'parent', - 'setAttr', - ], - decodeUpdate: ['ds', 'structs'], -} as const; +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) => d.share instanceof Map, + 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) => s.clients instanceof Map, + 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), + }, + StackItem: { + deletes: (s: StackItem) => isObject(s.deletes), + inserts: (s: StackItem) => isObject(s.inserts), + meta: (s: StackItem) => s.meta instanceof Map, + }, + StackItemEvent: { + origin: (e: StackItemEvent) => isSet(e.origin), + stackItem: (e: StackItemEvent) => isObject(e.stackItem), + }, + StructStore: { + clients: (s: StructStore) => s.clients instanceof Map, + getClock: (s: StructStore) => isFunction(s.getClock), + getItem: (s: StructStore) => isFunction(s.getItem), + }, + Transaction: { + changed: (tr: Transaction) => tr.changed instanceof Map, + 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) => n._map instanceof 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), + }, + }; +} + +/** 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', 'update'], + Doc: [ + 'afterAllTransactions', + 'afterTransaction', + 'beforeObserverCalls', + 'beforeTransaction', + 'update', + ], UndoManager: ['stack-cleared', 'stack-item-added', 'stack-item-popped'], } as const; @@ -198,7 +290,7 @@ export interface YUnsupportedYjsData { /** * What the check found instead: for `'changed-internals'`, each missing * or changed part of the contract (`'getItemCleanStart'`, - * `'Item#parentSub'`, `'Item/7 (expected 8)'`, `'UndoManager@stack-cleared'`); + * `'Item#parentSub'`, `'UndoManager@stack-cleared'`); * for `'other-copy'`, what came from the other copy. */ readonly found: readonly string[]; @@ -284,13 +376,9 @@ export type YjsExports = ReturnType; export function probeYjs(y: YjsExports = yjsExports()): string[] { const found: string[] = []; const exportsOf = y as unknown as Record; - for (const name of [...Object.keys(YJS_EXPORTS), 'GC']) { - const value = exportsOf[name]; - const arity = (YJS_EXPORTS as Record)[name]; - if (typeof value !== 'function') { + for (const name of [...YJS_EXPORTS, 'GC']) { + if (typeof exportsOf[name] !== 'function') { found.push(name); - } else if (arity !== undefined && value.length !== arity) { - found.push(`${name}/${value.length} (expected ${arity})`); } } if (found.length > 0) { @@ -309,7 +397,7 @@ export function probeYjs(y: YjsExports = yjsExports()): string[] { /** * Exercise, on a scratch document, the kinds of objects the binding meets, - * and check every member of {@link YJS_MEMBERS} on one of each and every + * 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 { @@ -328,19 +416,17 @@ function probeMembers(y: YjsExports, found: string[]): void { try { doc.cleanupFormatting = false; const root = doc.get('probe'); - const origin = {}; - const deleteFilter = () => true; - const trackedOrigins = new Set([origin]); for (const event of YJS_EVENTS.Doc) { doc.on(event, () => events.add(`Doc@${event}`)); } const undoManager = new y.UndoManager(root, { captureTimeout: 0, - deleteFilter, - trackedOrigins, + 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}`); @@ -356,7 +442,7 @@ function probeMembers(y: YjsExports, found: string[]): void { root.insert(2, [new y.Node('probe')]); root.format(0, 1, {probe: true}); root.setAttr('probe', 1); - }, origin); + }, PROBE_ORIGIN); for (let it = root._start; it !== null; it = it.right) { sample('Item', it); sample('ID', it.id); @@ -379,22 +465,8 @@ function probeMembers(y: YjsExports, found: string[]): void { sample('GC', new y.GC(y.createID(1, 0), 1)); sample('RelativePosition', new y.RelativePosition(null, null, null, 0)); sample('decodeUpdate', y.decodeUpdate(y.encodeStateAsUpdate(doc))); - // Options are checked by what the constructor kept of them. - if (doc.gc === false) { - sample('DocOpts', {gc: false}); - } - const kept = undoManager as unknown as Record; - if ( - kept.captureTimeout === 0 && - kept.deleteFilter === deleteFilter && - kept.trackedOrigins === trackedOrigins - ) { - sample('UndoManagerOptions', { - captureTimeout: 0, - deleteFilter, - trackedOrigins, - }); - } + // Options are checked on what the constructor kept of them. + sample('DocOpts', doc); undoManager.undo(); undoManager.clear(); } catch (error) { @@ -411,13 +483,23 @@ function probeMembers(y: YjsExports, found: string[]): void { } // After a throw, only what was reached is checked (it usually names the // cause), then the throw itself. - for (const [kind, members] of Object.entries(YJS_MEMBERS)) { + const members = yjsMembers() as unknown as Record< + string, + Record boolean> + >; + for (const kind of Object.keys(members)) { const value = samples.get(kind); if (value === undefined && threw !== null) { continue; } - for (const member of members) { - if (value === undefined || !(member in value)) { + for (const [member, check] of Object.entries(members[kind])) { + let ok = false; + try { + ok = value !== undefined && check(value); + } catch { + // A getter that throws is a changed member too. + } + if (!ok) { found.push(`${kind}#${member}`); } } From 59d3ab3a7866b30c0b69d7e3084933212857937f Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 14:45:40 -0400 Subject: [PATCH 3/7] [lexical-y] Feature: Check each Yjs object before the probe changes it, and import nothing the binding does not ## Description Review follow-ups on the Yjs guard (gpt-6.1-sol review): - **Each kind is checked when its first object is made.** The probe set `doc.cleanupFormatting = false` before checking it, so a `@y/y` that renamed that switch passed (the assignment created the old name) and kept its format cleanup on. Members are now checked on the untouched object. - **No probe-only import.** The probe imported `GC` to construct one; in native ES modules a `@y/y` without that export would fail to link before the typed check. It now takes a GC struct from a garbage-collected scratch document (the items of a deleted node), and imports only what the binding already imports. - A kind the probe could not make is reported (` (not made)`). ## Test plan ### Before The new case (a `Doc` whose `cleanupFormatting` is renamed) passes the probe of the previous revision (the reviewer's repro: the format cleanup stayed on, 3 format items instead of 4). ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts Tests 37 passed (37) ``` --- .../src/__tests__/unit/YjsContract.test.ts | 20 +++++ .../lexical-y/src/internal/YjsContract.ts | 81 +++++++++++-------- 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts index cf048b3448e..f210019fd73 100644 --- a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts +++ b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts @@ -470,6 +470,26 @@ const CHANGES: [string, (y: YModule) => Partial, string[]][] = [ }), ['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 => ({ diff --git a/packages/lexical-y/src/internal/YjsContract.ts b/packages/lexical-y/src/internal/YjsContract.ts index 6bce5c86576..36308375053 100644 --- a/packages/lexical-y/src/internal/YjsContract.ts +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -21,7 +21,7 @@ import { encodeStateAsUpdate, findIndexSS, findRootTypeKey, - GC, + type GC, getItemCleanStart, type ID, type IdSet, @@ -333,8 +333,9 @@ function unsupportedYjsMessage({ /** * 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). `GC` is only - * probed, for the `GC` members. + * 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__ */ @@ -346,7 +347,6 @@ function yjsExports() { ContentString, ContentType, Doc, - GC, Item, Node: YNode, RelativePosition, @@ -376,7 +376,7 @@ export type YjsExports = ReturnType; export function probeYjs(y: YjsExports = yjsExports()): string[] { const found: string[] = []; const exportsOf = y as unknown as Record; - for (const name of [...YJS_EXPORTS, 'GC']) { + for (const name of YJS_EXPORTS) { if (typeof exportsOf[name] !== 'function') { found.push(name); } @@ -401,15 +401,37 @@ export function probeYjs(y: YjsExports = yjsExports()): string[] { * event of {@link YJS_EVENTS}. */ function probeMembers(y: YjsExports, found: string[]): void { - const samples = new Map(); + // 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(); const sample = (kind: string, value: unknown) => { - if (!samples.has(kind) && typeof value === 'object' && value !== null) { - samples.set(kind, value); + if (sampled.has(kind) || typeof value !== 'object' || value === null) { + return; + } + sampled.add(kind); + 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}#${member}`); + } } }; const events = new Set(); const doc = new y.Doc({gc: false}); + // A garbage collected document, for a GC struct (the items of a deleted + // node). + const collected = new y.Doc(); sample('Doc', doc); + sample('DocOpts', doc); sample('StructStore', doc.store); const um: {destroy?: () => void} = {}; let threw: unknown = null; @@ -462,11 +484,17 @@ function probeMembers(y: YjsExports, found: string[]): void { const ranges = ids.clients.get(1); sample('IdRanges', ranges); sample('IdRange', ranges && ranges.getIds()[0]); - sample('GC', new y.GC(y.createID(1, 0), 1)); + 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('decodeUpdate', y.decodeUpdate(y.encodeStateAsUpdate(doc))); - // Options are checked on what the constructor kept of them. - sample('DocOpts', doc); undoManager.undo(); undoManager.clear(); } catch (error) { @@ -477,39 +505,24 @@ function probeMembers(y: YjsExports, found: string[]): void { um.destroy(); } doc.destroy(); + collected.destroy(); } catch (error) { threw = threw || error; } } - // After a throw, only what was reached is checked (it usually names the - // cause), then the throw itself. - const members = yjsMembers() as unknown as Record< - string, - Record boolean> - >; - for (const kind of Object.keys(members)) { - const value = samples.get(kind); - if (value === undefined && threw !== null) { - continue; - } - for (const [member, check] of Object.entries(members[kind])) { - let ok = false; - try { - ok = value !== undefined && check(value); - } catch { - // A getter that throws is a changed member too. - } - if (!ok) { - found.push(`${kind}#${member}`); - } - } - } + // 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}`)) { From 474c885bf7ab3605a98c4bc7cfcc4f9d6b24ec07 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 15:14:05 -0400 Subject: [PATCH 4/7] [lexical-y] Feature: Check Yjs collections across realms and every content kind; resolve @y/y's unimported types in the contract test ## Description Second review round on the Yjs guard (gpt-6.1-sol delta review): - **Maps from any realm.** `instanceof Map` refused the supported `@y/y` when it runs in another realm (a VM context) while the same-copy document check accepted it. The six collection checks now test the Map brand (`Map.prototype.has.call`). - **Every content kind.** `AbstractContent#getContent` was checked on the first content only (a format); it is now checked on each kind the probe makes: text, formats, nodes and attribute values (`ContentAny`, and `ContentBinary` from a binary attribute), so an unusable attribute content no longer passes and fails later at `start()`. - **The contract test sees through `@y/y`'s unresolved types.** `@y/y`'s declarations name types they do not import (`Transaction#doc: Doc`, `RelativePosition#item: ID | null`; why apps need `skipLibCheck`), so values reached through them were `any` and their members invisible to the inventory (27 sites: `tr.doc`, `rel.item`, `store.getItem(...)`). The test now builds its program with those names declared as globals in a virtual file (test only), and fails on any remaining `any` value that comes from `@y/y` (a member it declares, a call of its method, an untyped local or callback parameter from one). That exposed one more use, `Skip#id`, now in the contract and probed. ## Test plan ### Before ``` - "usedButNotListed": [], + "usedButNotListed": [ + "Skip#id (internal/Binding.ts)", ``` (with the resolved types, before adding it). The realm test against the previous `instanceof Map` check: ``` AssertionError: expected [ 'Doc#share' ] to deeply equal [] ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts Tests 39 passed (39) ``` --- .../src/__tests__/unit/YjsContract.test.ts | 196 +++++++++++++++--- .../lexical-y/src/internal/YjsContract.ts | 52 ++++- 2 files changed, 211 insertions(+), 37 deletions(-) diff --git a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts index f210019fd73..b368c18b3a6 100644 --- a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts +++ b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts @@ -18,6 +18,7 @@ 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'; @@ -57,6 +58,82 @@ describe('the supported version', () => { }); }); +/** + * 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`, @@ -99,9 +176,9 @@ function inventory(): Map> { if (parsed === undefined) { throw new Error('tsconfig.json did not parse'); } - const program = ts.createProgram(files, {...parsed.options, noEmit: true}); - const checker = program.getTypeChecker(); 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 => { @@ -166,6 +243,53 @@ function inventory(): Map> { } 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(); @@ -195,32 +319,18 @@ function inventory(): Map> { } if ( receiver && - ts.isIdentifier(receiver) && - checker.getTypeAtLocation(receiver).flags & ts.TypeFlags.Any + checker.getTypeAtLocation(receiver).flags & ts.TypeFlags.Any && + fromYUntyped(receiver) ) { - // An untyped parameter of a callback given to a method of a value - // from @y/y (`tr.changed.forEach((subs, type) => ...)`, whose - // element type @y/y does not resolve): its members would escape the - // inventory. Type the parameter. - const symbol = checker.getSymbolAtLocation(receiver); - const decl = symbol && symbol.valueDeclaration; - const fn = decl && ts.isParameter(decl) ? decl.parent : undefined; - const call = fn && fn.parent; - if ( - decl && - !(decl as ts.ParameterDeclaration).type && - fn && - (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && - call && - ts.isCallExpression(call) && - ts.isPropertyAccessExpression(call.expression) && - fromY(call.expression.expression) - ) { - const {line} = sourceFile.getLineAndCharacterOfPosition( - receiver.getStart(), - ); - add(`untyped parameter '${receiver.text}' at line ${line + 1}`, file); - } + // 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( @@ -356,6 +466,20 @@ describe('the contract', () => { test('the installed @y/y passes the probe', () => { expect(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', () => { @@ -697,6 +821,24 @@ describe('with the supported @y/y', () => { 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( diff --git a/packages/lexical-y/src/internal/YjsContract.ts b/packages/lexical-y/src/internal/YjsContract.ts index 36308375053..73b5c402d22 100644 --- a/packages/lexical-y/src/internal/YjsContract.ts +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -112,6 +112,15 @@ const isObject = (value: unknown): boolean => 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 @@ -147,7 +156,7 @@ export function yjsMembers() { get: (d: Doc) => isFunction(d.get), off: (d: Doc) => isFunction(d.off), on: (d: Doc) => isFunction(d.on), - share: (d: Doc) => d.share instanceof Map, + share: (d: Doc) => isMap(d.share), store: (d: Doc) => isObject(d.store), transact: (d: Doc) => isFunction(d.transact), }, @@ -170,7 +179,7 @@ export function yjsMembers() { }, IdSet: { add: (s: IdSet) => isFunction(s.add), - clients: (s: IdSet) => s.clients instanceof Map, + clients: (s: IdSet) => isMap(s.clients), hasId: (s: IdSet) => isFunction(s.hasId), }, Item: { @@ -194,22 +203,25 @@ export function yjsMembers() { 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) => s.meta instanceof Map, + meta: (s: StackItem) => isMap(s.meta), }, StackItemEvent: { origin: (e: StackItemEvent) => isSet(e.origin), stackItem: (e: StackItemEvent) => isObject(e.stackItem), }, StructStore: { - clients: (s: StructStore) => s.clients instanceof Map, + clients: (s: StructStore) => isMap(s.clients), getClock: (s: StructStore) => isFunction(s.getClock), getItem: (s: StructStore) => isFunction(s.getItem), }, Transaction: { - changed: (tr: Transaction) => tr.changed instanceof Map, + changed: (tr: Transaction) => isMap(tr.changed), deleteSet: (tr: Transaction) => isObject(tr.deleteSet), doc: (tr: Transaction) => isObject(tr.doc), insertSet: (tr: Transaction) => isObject(tr.insertSet), @@ -234,7 +246,7 @@ export function yjsMembers() { YNode: { _hasFormatting: (n: YNode) => isBoolean(n._hasFormatting), _item: (n: YNode) => isSet(n._item), - _map: (n: YNode) => n._map instanceof Map, + _map: (n: YNode) => isMap(n._map), _searchMarker: (n: YNode) => isSet(n._searchMarker), _start: (n: YNode) => isSet(n._start), deleteAttr: (n: YNode) => isFunction(n.deleteAttr), @@ -408,10 +420,17 @@ function probeMembers(y: YjsExports, found: string[]): void { Record boolean> >; const sampled = new Set(); - const sample = (kind: string, value: unknown) => { - if (sampled.has(kind) || typeof value !== 'object' || value === null) { + const checked = new Set(); + /** Check `value` as a `kind`, once per kind (and `variant` of it). */ + const sample = (kind: string, value: unknown, variant = '') => { + if ( + checked.has(kind + '/' + variant) || + typeof value !== 'object' || + value === null + ) { return; } + checked.add(kind + '/' + variant); sampled.add(kind); for (const [member, check] of Object.entries(members[kind])) { let ok = false; @@ -421,7 +440,7 @@ function probeMembers(y: YjsExports, found: string[]): void { // A getter that throws is a changed member too. } if (!ok) { - found.push(`${kind}#${member}`); + found.push(`${kind}${variant === '' ? '' : `(${variant})`}#${member}`); } } }; @@ -464,11 +483,15 @@ function probeMembers(y: YjsExports, found: string[]): void { root.insert(2, [new y.Node('probe')]); root.format(0, 1, {probe: true}); root.setAttr('probe', 1); + root.setAttr('probeBinary', new Uint8Array([1])); }, 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); - sample('AbstractContent', it.content); + contents.push(it.content); if (it.content instanceof y.ContentString) { sample('ContentString', it.content); } else if (it.content instanceof y.ContentFormat) { @@ -478,6 +501,14 @@ function probeMembers(y: YjsExports, found: string[]): void { sample('YNode', it.content.type); } } + root._map.forEach(item => contents.push(item.content)); + for (const content of contents) { + sample( + 'AbstractContent', + content, + (content as {constructor: {name: string}}).constructor.name, + ); + } const ids = y.createIdSet(); ids.add(1, 0, 2); sample('IdSet', ids); @@ -494,6 +525,7 @@ function probeMembers(y: YjsExports, found: string[]): void { 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(); From ec14689a7a2552dd2ceea486e35656a55f4ede71 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 15:32:30 -0400 Subject: [PATCH 5/7] [lexical-y][lexical-website] Feature: Probe Yjs in any realm and minified build, and draw only two client ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Third review round on the Yjs guard (gpt-6.1-sol delta review), and a soak note: - **Content classes by identity.** The per-kind content check deduplicated classes by name; a minified build (esbuild `--minify`, Terser) names them all `e`, so only the first was checked. Classes are now told apart by constructor identity, the name kept only as the label. - **Bytes from `@y/y`'s own realm.** The binary attribute sample was a `Uint8Array` of `@lexical/y`'s realm; `@y/y` picks content by the value's constructor, so with the two in different realms (a VM-pool runner, an embedder) the probe threw `Unexpected content type` on the supported copy. It now uses bytes `@y/y` made (`encodeStateAsUpdate`). - **Fixed guid on the scratch documents.** Each now draws only its client id from the app's randomness, not also a uuid (32 draws). The tp2 soak, which seeds Yjs's randomness to replay sessions, saw a later document's random client id shift by the probe's draws and a tie-break flip (main + two throwaway documents reproduced it; the class is main's own, at other ids). `y.md` says so for tests that seed randomness. ## Test plan ### Before Both new tests against the previous `YjsContract.ts`: ``` × content classes are told apart by identity, not name AssertionError: expected [] to deeply equal [ 'AbstractContent(e)#getContent' ] × the check runs in another realm than @y/y + "probe threw: Error: Unexpected content type", ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts Tests 41 passed (41) ``` --- .../lexical-website/docs/collaboration/y.md | 4 +- .../src/__tests__/unit/YjsContract.test.ts | 54 +++++++++++++++++++ .../lexical-y/src/internal/YjsContract.ts | 45 +++++++++------- 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 74bf2ecee27..217f66f7b46 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -67,7 +67,9 @@ The fix for both 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. A different `@y/y` that happens to provide the same internals passes it, so pin the version all the -same. +same. 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 diff --git a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts index b368c18b3a6..e98cdb4ba3a 100644 --- a/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts +++ b/packages/lexical-y/src/__tests__/unit/YjsContract.test.ts @@ -467,6 +467,60 @@ describe('the contract', () => { 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; diff --git a/packages/lexical-y/src/internal/YjsContract.ts b/packages/lexical-y/src/internal/YjsContract.ts index 73b5c402d22..43dbaa4fa55 100644 --- a/packages/lexical-y/src/internal/YjsContract.ts +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -265,6 +265,7 @@ export function yjsMembers() { }; } +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; @@ -420,18 +421,23 @@ function probeMembers(y: YjsExports, found: string[]): void { Record boolean> >; const sampled = new Set(); - const checked = new Set(); - /** Check `value` as a `kind`, once per kind (and `variant` of it). */ - const sample = (kind: string, value: unknown, variant = '') => { - if ( - checked.has(kind + '/' + variant) || - typeof value !== 'object' || - value === null - ) { + /** 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(kind + '/' + variant); + checked.add(variant); sampled.add(kind); + const variantLabel = label; for (const [member, check] of Object.entries(members[kind])) { let ok = false; try { @@ -440,15 +446,19 @@ function probeMembers(y: YjsExports, found: string[]): void { // A getter that throws is a changed member too. } if (!ok) { - found.push(`${kind}${variant === '' ? '' : `(${variant})`}#${member}`); + found.push( + `${kind}${variantLabel === '' ? '' : `(${variantLabel})`}#${member}`, + ); } } }; const events = new Set(); - const doc = new y.Doc({gc: false}); + // 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(); + const collected = new y.Doc({guid: PROBE_GUID}); sample('Doc', doc); sample('DocOpts', doc); sample('StructStore', doc.store); @@ -483,7 +493,9 @@ function probeMembers(y: YjsExports, found: string[]): void { root.insert(2, [new y.Node('probe')]); root.format(0, 1, {probe: true}); root.setAttr('probe', 1); - root.setAttr('probeBinary', new Uint8Array([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. @@ -503,11 +515,8 @@ function probeMembers(y: YjsExports, found: string[]): void { } root._map.forEach(item => contents.push(item.content)); for (const content of contents) { - sample( - 'AbstractContent', - content, - (content as {constructor: {name: string}}).constructor.name, - ); + const klass = (content as {constructor: {name: string}}).constructor; + sample('AbstractContent', content, klass, klass.name || '?'); } const ids = y.createIdSet(); ids.add(1, 0, 2); From 0c236365cdce1c288fc2e5ca3009a94257a35628 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 16:08:18 -0400 Subject: [PATCH 6/7] [lexical-y][lexical-website] Documentation Update: Record the Yjs guard in the decision register and Limits ## Description Follows the merge of `main` (which brought the decision register) and the review bot's optional items: - `DECISIONS.md` D14 now says the runtime guard ships: `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, unlike D07's read-only warning for remote data. New `ACCEPTED LIMIT` D74: a shape-compatible `@y/y` passes, and a provider that bundles its own Yjs copy is not caught. - `y.md` Limits lists the same. - README: "Its type declarations" now names `@y/y`. ## Test plan ### After Docs and the register only, on the merged tree: `pnpm run ci-check` and the `@lexical/y` unit suite (see the PR's test plan for the counts). --- packages/lexical-website/docs/collaboration/y.md | 7 +++++++ packages/lexical-y/DECISIONS.md | 4 +++- packages/lexical-y/README.md | 5 +++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 8234ac7394c..71daedb5aae 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1108,6 +1108,13 @@ 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 guard cannot tell apart.** `@lexical/y` refuses a + `@y/y` that lacks an export, member or event it uses (see + [Install](#install)), but a `@y/y` with the same ones passes, 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. Install exactly 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 022b4ee7b1f..7dfe9f09241 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] | +| D74 | **The Yjs guard (D14) cannot tell every other Yjs apart:** a `@y/y` with the same exports, members and events passes, 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 @@ -181,3 +182,4 @@ issues and docs that carry it. [#9589]: https://github.com/meta/lexical/issues/9589 [#9595]: https://github.com/meta/lexical/pull/9595 [#9596]: https://github.com/meta/lexical/pull/9596 +[#9607]: https://github.com/meta/lexical/pull/9607 diff --git a/packages/lexical-y/README.md b/packages/lexical-y/README.md index 5a74904af68..081c34a354a 100644 --- a/packages/lexical-y/README.md +++ b/packages/lexical-y/README.md @@ -17,8 +17,9 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \ supports: install exactly that version, once. `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. Its type declarations do not type-check on their own (an -upstream issue): projects that run `tsc` need `skipLibCheck: true`. +the document comes from a second copy. `@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 From 906f6a3935fed12389769b3896acc513701aa651 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 16:50:45 -0400 Subject: [PATCH 7/7] [lexical-y][lexical-website] Feature: Keep the Yjs guard green on main; say plainly that the peer dependency enforces the exact version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description After merging `main` (#9550, #9559): - **#9550's Seen guard** (`Source.test.ts`, "internal writes of node attributes go through Seen") flagged `YjsContract.ts`: its probe writes to scratch documents of its own, never a document a binding reads, so there is no Seen record to keep. The file is excluded there, with that reason. - **#9559 uses `encodeStateVector`**, which the contract test listed as used but not in the contract: added to `YJS_EXPORTS` (checked as a function). And, on how tight the coupling is: `@y/y` exposes no version at run time (no export or constant; 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 none synchronously), so the exact version is the peer dependency's to enforce and the run-time check is a shape check. Measured: `14.0.0-rc.24` and earlier fail at build or load (no `Node` export: esbuild `No matching export ... for import "Node"`, native ES modules `SyntaxError`), and `rc.25`, `rc.26` and `rc.28` pass the probe. The changeset summary (it read as an exact-version check), `y.md` Install and Limits, the README and D79 now say so. ## Test plan ### Before On the merged tree, before this commit: ``` × internal writes of node attributes go through Seen + "YjsContract.ts probeMembers: root.setAttr('probe')", ... (6 entries) × lists exactly what the sources use of @y/y + "export encodeStateVector (internal/Binding.ts)", ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/YjsContract.test.ts packages/lexical-y/src/__tests__/unit/Source.test.ts Tests 45 passed (45) ``` Per release candidate (the contract bundled with esbuild, `@y/y` external, run in Node against each install): ``` rc.24 load error: The requested module '@y/y' does not provide an export named 'Node' rc.25 [] rc.26 [] rc.28 [] rc.29 [] ``` --- .changeset/lexical-y-yjs-version-guard.md | 17 ++++--- .../lexical-website/docs/collaboration/y.md | 49 ++++++++++++------- packages/lexical-y/DECISIONS.md | 2 +- packages/lexical-y/README.md | 12 +++-- .../src/__tests__/unit/Source.test.ts | 8 ++- .../lexical-y/src/internal/YjsContract.ts | 10 +++- 6 files changed, 65 insertions(+), 33 deletions(-) diff --git a/.changeset/lexical-y-yjs-version-guard.md b/.changeset/lexical-y-yjs-version-guard.md index 6f3b4d578b6..a00ab21a7fa 100644 --- a/.changeset/lexical-y-yjs-version-guard.md +++ b/.changeset/lexical-y-yjs-version-guard.md @@ -2,19 +2,22 @@ '@lexical/y': patch --- -[feature] Refuse to run against a Yjs other than the one `@lexical/y` supports, with a typed `YUnsupportedYjsError`, before anything is written +[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`. 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 +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 internals the binding uses, and that -the document comes from that same copy. Otherwise they throw a +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'`. +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 fc623e3f7b7..dbd04a99e5f 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -51,25 +51,37 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \ **`@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 binding writes -through Yjs internals, so `createYDoc`, `YBinding` (and so -`YCollabExtension`) and the headless helpers check, once per app, that the -`@y/y` they loaded provides what the binding uses, and throw a +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 another `@y/y` (pnpm - only warns about an unmet peer dependency, and npm installs one with - `--legacy-peer-deps`); `data.found` lists what differs. +- `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`. -The fix for both is the same: install `@y/y@` once +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. A different `@y/y` that -happens to provide the same internals passes it, so pin the version all the -same. 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). +`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 @@ -1196,13 +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 guard cannot tell apart.** `@lexical/y` refuses a - `@y/y` that lacks an export, member or event it uses (see - [Install](#install)), but a `@y/y` with the same ones passes, even if its +- **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. Install exactly the `@y/y` version `@lexical/y` names, once - (`npm ls @y/y`). + 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 e29457313d4..5393d6811fd 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -114,7 +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:** a `@y/y` with the same exports, members and events passes, 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] | +| 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 diff --git a/packages/lexical-y/README.md b/packages/lexical-y/README.md index 081c34a354a..aaff30ad9f6 100644 --- a/packages/lexical-y/README.md +++ b/packages/lexical-y/README.md @@ -14,10 +14,14 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \ ``` `@y/y` is a peer dependency pinned to the one release candidate this package -supports: install exactly that version, once. `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. `@y/y`'s type declarations do not +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`. diff --git a/packages/lexical-y/src/__tests__/unit/Source.test.ts b/packages/lexical-y/src/__tests__/unit/Source.test.ts index 8b2fddc6b3a..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( diff --git a/packages/lexical-y/src/internal/YjsContract.ts b/packages/lexical-y/src/internal/YjsContract.ts index 43dbaa4fa55..3fe542c4f33 100644 --- a/packages/lexical-y/src/internal/YjsContract.ts +++ b/packages/lexical-y/src/internal/YjsContract.ts @@ -19,6 +19,7 @@ import { decodeUpdate, Doc, encodeStateAsUpdate, + encodeStateVector, findIndexSS, findRootTypeKey, type GC, @@ -47,8 +48,11 @@ import {SUPPORTED_YJS_VERSION} from './SupportedYjs'; * 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 (its `package.json` subpath is not something every - * bundler or runtime can import), so the check probes the contract itself, + * 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 @@ -86,6 +90,7 @@ export const YJS_EXPORTS = [ 'createRelativePositionFromJSON', 'decodeUpdate', 'encodeStateAsUpdate', + 'encodeStateVector', 'findIndexSS', 'findRootTypeKey', 'getItemCleanStart', @@ -371,6 +376,7 @@ function yjsExports() { createRelativePositionFromJSON, decodeUpdate, encodeStateAsUpdate, + encodeStateVector, findIndexSS, findRootTypeKey, getItemCleanStart,