Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/lexical-y-yjs-version-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@lexical/y': patch
---

[feature] Refuse a Yjs that lacks the internals `@lexical/y` uses, or a document from a second copy of Yjs, with a typed `YUnsupportedYjsError` before anything is written

`@lexical/y` supports exactly one `@y/y` version, its exact peer dependency,
now also exported as `SUPPORTED_YJS_VERSION`. The exact version is enforced
by that peer dependency: `@y/y` exposes no version at run time. Package
managers only warn about an unmet peer dependency (pnpm by default, npm
with `--legacy-peer-deps`), so an app could still run another `@y/y` and
misbehave without an error. `createYDoc`, `YBinding` (and so
`YCollabExtension`) and the headless helpers now check, once per app, that
the `@y/y` they loaded provides the exports, fields and events the binding
uses, and that the document comes from that same copy. Otherwise they throw a
`YUnsupportedYjsError` (`code: UNSUPPORTED_YJS_ERROR`) with
`data: {supported, found, reason}`, where `reason` is `'changed-internals'`
or `'other-copy'`. A `@y/y` older than `14.0.0-rc.25` fails earlier, at
build or load, with a missing `Node` export; `rc.25` to `rc.28` provide
everything the binding uses and pass the check, so pin the named version.

Verify: with `@y/y@14.0.0-rc.29` installed once (`npm ls @y/y`), nothing
changes.
45 changes: 43 additions & 2 deletions packages/lexical-website/docs/collaboration/y.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,41 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \
@y/y@14.0.0-rc.29 @y/websocket@4.0.0-rc.2
```

`@y/y` is a peer dependency pinned to the release candidate `@lexical/y` is
tested with; npm's `latest` tag points at an older one, so name the version.
**`@lexical/y` supports exactly one Yjs version: install the `@y/y`
version it names** (its exact peer dependency, also exported as
`SUPPORTED_YJS_VERSION`), and only one copy of it. npm's `latest` tag points
at an older release candidate, so name the version. The exact version is
enforced by that peer dependency: `@y/y` exposes no version at run time, so
`@lexical/y` cannot compare it there. What it checks at run time is the
Yjs it writes through: `createYDoc`, `YBinding` (and so `YCollabExtension`)
and the headless helpers check, once per app, that the `@y/y` they loaded
provides the exports, fields and events the binding uses, and throw a
`YUnsupportedYjsError` before anything is written when it does not:

- `data.reason: 'changed-internals'`: the app runs a `@y/y` without
something the binding uses (pnpm only warns about an unmet peer
dependency, and npm installs one with `--legacy-peer-deps`); `data.found`
lists what differs.
- `data.reason: 'other-copy'`: the document (or `root`) comes from a second
copy of `@y/y`.

A release candidate older than `14.0.0-rc.25` (npm's `latest` among them)
fails earlier, when the app is built or loaded, because it lacks the
`Node` export: esbuild reports `No matching export in
"node_modules/@y/y/src/index.js" for import "Node"` (other bundlers word it
their own way), and native ES modules throw `SyntaxError: The requested module
'@y/y' does not provide an export named 'Node'`. One that provides
everything the binding uses passes the check (`14.0.0-rc.25` to `rc.28`
do), and so would a later one that kept the same shape with different
behaviour, which is why the peer dependency names one version.

The fix in every case is the same: install `@y/y@<data.supported>` once
(`npm ls @y/y` shows one version, deduped) and create documents with
`createYDoc`. There is no option to skip the check. The check makes two
scratch documents, once, and each draws a client id: a test that seeds
Yjs's randomness to replay documents sees the random ids of later
documents shift (give those documents explicit client ids).

The type declarations `@y/y` ships do not type-check on their own (an
upstream issue): if you run `tsc`, set `skipLibCheck: true`. Lexical's types
also need TypeScript 5.2 or later with `moduleResolution` set to `bundler`,
Expand Down Expand Up @@ -1175,6 +1208,14 @@ not merge as a user might expect:
place (or replaced by a block node) sends it to the start of the line
after, or to the end of the line before where a block node follows,
never onto the block node.
- **Yjs versions the run-time check cannot tell apart.** `@y/y` exposes no
version at run time, so `@lexical/y` refuses a `@y/y` that lacks an
export, member or event it uses (see [Install](#install)), but one with
the same ones passes (`14.0.0-rc.25` to `rc.28` do), even if its
functions behave differently, and so does a provider that bundles its own
copy of Yjs and applies updates to your document after the binding
started. The exact version is the peer dependency's to enforce: install
the `@y/y` version `@lexical/y` names, once (`npm ls @y/y`).
- **NodeState key order.** Other clients (and any later reader) get a node's
NodeState with its keys in sorted order, which every client builds the
same way, not in the order the author's editor exported them (a Yjs map
Expand Down
4 changes: 3 additions & 1 deletion packages/lexical-y/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,6 +114,7 @@ issues and docs that carry it.
| D56 | **Initial content that differs between clients shows twice** (random ids, different block id configurations, a root that held content). | Seeding merges only identical bytes (D22); seed in one place. | `ACCEPTED LIMIT` | 10-25 | [limits] |
| D57 | **Lone surrogates** reach other clients as U+FFFD. | Yjs encodes text as UTF-8; Yjs 13 does the same. Malformed text such as a broken emoji is the author's own input, not an editor problem, so there is no code follow-up. | `ACCEPTED LIMIT` | 10-30 | [limits] |
| D58 | **Inline elements with their own content** (`MarkNode`, `AutoLinkNode`) move text into them when they wrap it, so concurrent typing there stays outside. | Use NodeState marks (D37). | `ACCEPTED LIMIT` | 10-20 | [limits] |
| D79 | **The Yjs guard (D14) cannot tell every other Yjs apart:** `@y/y` exposes no version at run time, so the guard checks shape and the peer dependency enforces the exact version; a `@y/y` with the same exports, members and events passes (`14.0.0-rc.25` to `rc.28` do), as does one whose functions behave differently with the same shape, and a provider that bundles its own copy of Yjs and applies updates to the app's document after the binding started is not caught. | `@y/y` exposes no version at run time, and checking updates would cost on every one. The exact peer pin and the docs (install the named version once; `npm ls @y/y`) cover them. | `ACCEPTED LIMIT` | 10-30 | [#9607], [limits] |

## Open

Expand Down Expand Up @@ -193,6 +194,7 @@ issues and docs that carry it.
[#9599]: https://github.com/meta/lexical/issues/9599
[#9603]: https://github.com/meta/lexical/issues/9603
[#9605]: https://github.com/meta/lexical/pull/9605
[#9607]: https://github.com/meta/lexical/pull/9607
[#9608]: https://github.com/meta/lexical/issues/9608
[#9609]: https://github.com/meta/lexical/issues/9609
[#9610]: https://github.com/meta/lexical/issues/9610
30 changes: 27 additions & 3 deletions packages/lexical-y/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ npm i --save-exact @lexical/y@nightly @lexical/extension@nightly \
@lexical/rich-text@nightly lexical@nightly @y/y@14.0.0-rc.29
```

`@y/y` is a peer dependency pinned to the release candidate this package is
tested with. Its type declarations do not type-check on their own (an
upstream issue): projects that run `tsc` need `skipLibCheck: true`.
`@y/y` is a peer dependency pinned to the one release candidate this package
supports: install exactly that version, once. The peer dependency enforces
the version (`@y/y` exposes none at run time); at run time `createYDoc`,
`YBinding` and the headless helpers throw a `YUnsupportedYjsError`
(`data.supported` names the version) when the `@y/y` the app loaded lacks
what the binding uses, or the document comes from a second copy. A release
candidate older than `14.0.0-rc.25` (npm's `latest` among them) fails
earlier, at build or load, with a missing `Node` export. `@y/y`'s type
declarations do not
type-check on their own (an upstream issue): projects that run `tsc` need
`skipLibCheck: true`.

Until a Lexical release includes `@lexical/y`, npm's `latest` for it is a
placeholder: install every Lexical package from the same nightly, in one
Expand Down Expand Up @@ -103,6 +111,22 @@ To replay one seed with its trace: `FUZZ_SEED=<seed>` (add `FUZZ_GC=1` for
the garbage-collected run), `NORMALIZE_FUZZ_TRACE=<seed>` or
`STORAGE_FUZZ_TRACE=<seed>`.

## 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`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: packages/lexical-playground/package.json pins @y/y on its own, and no workspace override ties it to this package, so this step could name it too. Otherwise a bump leaves the playground on the old version, and its document (from createYDoc) and @y/websocket resolve different copies of Yjs, the case D79 says the guard cannot see.

then run `pnpm install` and `pnpm run update-version` (which regenerates
`SUPPORTED_YJS_VERSION` in `src/internal/SupportedYjs.ts`).
2. Run `YjsContract.test.ts`: update the exports, members and events in
`src/internal/YjsContract.ts` to what the binding uses of the new
version, and re-check each changed one in the code that uses it.
3. Run the equivalence test (`Cursor.test.ts`, the binding's cursor against
`Y.Node#applyDelta`), the catalog matrix (`src/__tests__/unit/matrix`)
and the long fuzz above, then update the version in the install
commands here and in the docs (`docs/collaboration/y.md`).

## Storage

The shared root is `doc.get('lexical-y')` (`DEFAULT_ROOT_NAME`). How an
Expand Down
22 changes: 22 additions & 0 deletions packages/lexical-y/flow/LexicalY.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,28 @@ export type YDocOptions = {
shouldLoad?: boolean,
};

// The one @y/y version this @lexical/y supports (its exact peer dependency).
declare export var SUPPORTED_YJS_VERSION: string;

declare export var UNSUPPORTED_YJS_ERROR: '@lexical/y/unsupported-yjs';

export type YUnsupportedYjsReason = 'changed-internals' | 'other-copy';

export type YUnsupportedYjsData = {
readonly supported: string,
readonly found: ReadonlyArray<string>,
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@<data.supported>, 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(
Expand Down
4 changes: 4 additions & 0 deletions packages/lexical-y/flow/LexicalYHeadless.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions packages/lexical-y/flow/__flowtests__/unsupportedYjs.js.flow
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 11 additions & 1 deletion packages/lexical-y/src/__tests__/unit/Source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -169,15 +175,18 @@ test('public entries', () => {
'RECOVERED_WRITE_WARNING',
'SELF_CHECK_WARNING',
'STORAGE_MISMATCH_WARNING',
'SUPPORTED_YJS_VERSION',
'UNKNOWN_NODE_TYPES_WARNING',
'UNSUPPORTED_CONFIG_WARNING',
'UNSUPPORTED_EDIT_WARNING',
'UNSUPPORTED_LAYOUT_WARNING',
'UNSUPPORTED_SCHEMA_WARNING',
'UNSUPPORTED_SLOTS_WARNING',
'UNSUPPORTED_YJS_ERROR',
'UNSYNCED_VALUE_WARNING',
'YBinding',
'YCollabExtension',
'YUnsupportedYjsError',
'createYDoc',
'describeStorage',
'isYWarning',
Expand All @@ -187,6 +196,7 @@ test('public entries', () => {
'FOLLOW_UPS_EXHAUSTED_WARNING',
'WRITER_NEW_CLIENT_ID_WARNING',
'YHeadlessError',
'YUnsupportedYjsError',
'createYDoc',
'createYWriter',
'editYState',
Expand Down
Loading
Loading