Skip to content

[lexical-y][lexical-website] Feature: Refuse to run against a Yjs other than the one @lexical/y supports - #9607

Merged
zurfyx merged 9 commits into
mainfrom
zurfyx/y-yjs-version-guard
Oct 30, 2026
Merged

zurfyx merged 9 commits into
mainfrom
zurfyx/y-yjs-version-guard

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

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 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 enforced by the peer dependency,
    and at run time the guard checks the contract the binding uses: a shape
    check. Measured per release candidate: 14.0.0-rc.24 and earlier fail at
    build or load (no Node export), rc.25, rc.26 and rc.28 pass the
    probe.
    src/internal/YjsContract.ts lists every @y/y export the binding uses
    (24), every member it reads or calls by declaring type (84:
    Item#parentSub, YNode#_start, Doc#_transactionCleanups,
    UndoManagerOptions#deleteFilter, ...) and every event it listens to
    (8). probeYjs checks them on two scratch documents (fixed guid, so
    each draws only a client id): every export is a function, the first
    object of each kind (each content class, told apart by identity) passes
    each member's check before the probe changes it, every event fires. It
    works with @y/y in another realm (Maps by brand, bytes made by @y/y)
    and in minified builds. Members are checked by property reads typed against @y/y's
    declarations with a check of the value ((d: Doc) => Array.isArray(d._transactionCleanups), (u: UndoManager) => isFunction(u.canRedo)), not by name: tsc fails on a member the
    declarations drop, an emptied method or a null field is caught, and a
    build that renames properties renames them in the probe as in the
    binding. Arities are not compared: transpilers change Function#length.
  • 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 is or extends a @y/y type,
    YDoc and 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. @y/y's
    declarations name types they do not import (Transaction#doc: Doc,
    RelativePosition#item: ID | null, the reason apps need
    skipLibCheck), so the test declares those names 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). 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, members and events
    passes, as does one whose functions behave differently with the same
    shape. Nothing at run time can tell it apart, so the exact pin
    and the docs still say to install the named version.
  • In native ES modules (no bundler), a @y/y without one of the exports
    the binding imports fails to link with a SyntaxError naming the export,
    before any code runs: fail-closed too, though not with the typed error.
    The probe imports nothing the binding does not.
  • The probe's two scratch documents each draw a client id once: a test
    that seeds Yjs's randomness sees later random ids shift (the tp2 soak
    did; y.md says to give such documents explicit client ids).
  • 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.

Decision register: D14 now records the guard (no opt-out, unlike D07's
read-only warning for remote data); new ACCEPTED LIMIT D79 for what it
cannot tell apart, also in y.md's Limits.

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  45 passed (45)

$ pnpm exec vitest run --project unit packages/lexical-y
 Test Files  197 passed | 2 skipped (199)
      Tests  11542 passed | 204 expected fail | 2 skipped (11748)

$ pnpm exec vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
     ✓ @lexical/y (packages/lexical-y/src/index.ts)
      Tests  378 passed (378)

$ pnpm run ci-check
Changeset check passed: 52 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 installed @y/y passes; it passes from another realm
and with every content class named alike (a minified build), where an
unusable attribute content is still caught; and, with @y/y mocked to a
missing export, an emptied method, a null field, a missing member, a
renamed field the probe assigns 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; a
build that changes arities or whose Maps come from another realm passes;
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).

(Counts on the branch merged with main at be5afea88816, which includes
#9550 and #9559: YjsContract.ts is excluded from #9550's Seen guard, as
it writes only to its own scratch documents, and #9559's
encodeStateVector is in the contract.)

Reviews: gpt-6.1-sol, four rounds (r1: 4 major, 2 minor; then 1 major,
2 minor; then 2 major; then none at major or above), all fixed in the
follow-up commits; the review bot approved ec14689 and 0c23636, and
its optional items (D14, Limits, wording of the changeset summary, the
missing-Node build error) are in the commits since. Downstream
suites and a soak on main + this PR: no difference attributable to it.

…er than the one @lexical/y supports

## 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).
@vercel

vercel Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 30, 2026 8:55pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 8:55pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 30, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9607

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9607

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9607

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9607

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9607

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9607

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9607

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9607

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9607

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9607

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9607

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9607

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9607

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9607

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9607

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9607

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9607

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9607

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9607

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9607

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9607

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9607

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9607

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9607

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9607

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9607

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9607

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9607

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9607

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9607

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9607

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9607

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9607

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9607

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9607

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9607

commit: 906f6a3

…rities; 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.
@meta-code-review

meta-code-review Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 30, 2026, 9:22 PM UTC

…t, 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 (`<kind> (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)
```
…ntent 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)
```
…fied build, and draw only two client ids

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

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks good to me, thanks! The guard refuses in exactly the places that matter (createYDoc, the YBinding constructor and so YCollabExtension, and the headless helpers) before anything is written, the drift test keeps the contract lists tied to what the sources actually use, and an app with the supported @y/y sees no change. A few optional, non-blocking suggestions:

  1. Record the guard in the decision register (optional). On main, packages/lexical-y/DECISIONS.md row D14 still says “A runtime guard on the Yjs version is a follow-up”, and packages/lexical-y/AGENTS.md asks a pull request that makes or changes a recorded decision to update its row in the same pull request and to list accepted limits in y.md's Limits. Both landed after this branch was cut. When you rebase, consider updating D14 to say the guard ships and that it throws with no opt-out (like YCollabExtension's existing build-time invariants, rather than D07's read-only warning path, so a mismatched @y/y stops the editor from building), and listing its limits (a shape-compatible @y/y passes; a provider that bundles its own Yjs is not caught) under ## Limits in packages/lexical-website/docs/collaboration/y.md.
  2. Refresh the test plan output (optional). The ### Before (23 tests) and ### After (41 tests across YjsContract.test.ts and Source.test.ts) blocks appear to come from an earlier revision; YjsContract.test.ts now has 41 tests on its own. AGENTS.md (Commits and Pull Requests) asks for the real output of the command as run.
  3. README wording (nit). In packages/lexical-y/README.md line 20, “Its type declarations…” now follows “the document comes from a second copy”, so “Its” no longer clearly means @y/y; “@y/y's type declarations…” reads unambiguously.

Comment thread packages/lexical-y/README.md Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit (optional): with the new sentences in front of it, “Its” now follows “the document comes from a second copy” and no longer clearly means @y/y. Suggest starting this sentence with “@y/y's type declarations do not type-check on their own…”.

## Description

Merges `main` at 8e89481 (11 commits, including the `@lexical/y`
decision register) so the branch can update D14 in the same pull request.
No conflicts.

## Test plan

### After

`pnpm run ci-check` and the `@lexical/y` unit suite on the merged tree (see
the next commit and the PR's test plan).
…rd 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).

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks, this looks good. The guard fails closed in the right places (createYDoc, the YBinding constructor and so YCollabExtension, and the headless helpers, which now create their document before building the editor so a refusal registers nothing), and the new exports line up across TypeScript and Flow. The contract test that derives every @y/y use from the sources is a good way to keep YjsContract.ts in step as Yjs moves.

A few optional, non-blocking suggestions:

  1. Changeset summary (.changeset/lexical-y-yjs-version-guard.md, line 5). It reads as an exact-version check, but the probe checks shape: @y/y 14.0.0-rc.25, rc.26 and rc.28 pass it unchanged, and rc.24 and earlier are refused because they lack the Node export. The body, y.md's Limits and D74 already say so, but the changelog line is what most readers see (.changeset/README.md: the summary describes the change from the user's side). Something like [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 would match. The PR title uses the same wording.
  2. The missing-export case in the docs (packages/lexical-website/docs/collaboration/y.md, Install, and the install note in packages/lexical-y/README.md). For npm's latest (14.0.0-rc.7) through rc.24 the binding's Node import does not exist, so an app stops before the check runs: an esbuild build fails with No matching export … for import "Node" and native ES modules throw SyntaxError: The requested module '@y/y' does not provide an export named 'Node'. One sentence naming that error would point readers to the same fix; the description already lists it under Known limits.
  3. Test plan Before block. The pasted 18 failed | 5 passed (23) predates the current YjsContract.test.ts (41 tests, six changed-Yjs cases). AGENTS.md asks the Test plan to carry the real Before and After output of the command that was run, so re-pasting it keeps the squash commit accurate.
  4. Timing in the description. "About 0.1 ms" matches a warm probe, but the probe only ever runs once, cold, where it takes a few milliseconds. It is still a one-time startup cost, so only the figure needs updating.

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

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: this line reads as an exact-version check, but the probe checks shape: @y/y 14.0.0-rc.25, rc.26 and rc.28 pass it unchanged, and rc.24 and earlier are refused because they lack the Node export. Since the changelog line is what most readers see (.changeset/README.md: the summary describes the change from the user's side), something like:

[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

Comment on lines +55 to +58
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:

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: for npm's latest (14.0.0-rc.7) through rc.24, the missing piece is the Node export, so an app stops before this check runs: an esbuild build fails with No matching export … for import "Node" and native ES modules throw SyntaxError: The requested module '@y/y' does not provide an export named 'Node'. A sentence naming that error here (and in the README's install note) would point readers to the same fix.

## Description

Merges `main` at be5afea88816 (including #9550 and #9559). One conflict, in
the link definitions of `packages/lexical-y/DECISIONS.md`, resolved by
keeping both sides; the guard's accepted-limit row is renumbered D79, as
`main` now uses D74 to D78.

## Test plan

### After

See the next commit, which fixes the two tests the merge breaks, and the
PR's test plan.
…n; say plainly that the peer dependency enforces the exact version

## 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 []
```

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks good: the guard refuses before anything is written, the new exports line up between TypeScript and Flow (and the headless entry re-exports the same error class), and the drift test keeps YjsContract.ts in step with what the sources use. The fail-closed behavior with no opt-out is the documented intent (D14, D79 and the Limits in y.md), so it is not raised here. A few optional, non-blocking suggestions:

  1. packages/lexical-y/README.md, "Bumping @y/y", step 1 (line 119). packages/lexical-playground/package.json pins @y/y on its own, and nothing in pnpm-workspace.yaml ties it to this package's pin. Following the checklist as written would leave the playground on the old version, so its document (from createYDoc) and @y/websocket would resolve different copies of Yjs, which is the provider case D79 says the guard cannot detect. Consider naming the playground pin in step 1.
  2. packages/lexical-y/src/internal/SupportedYjs.ts:17. SUPPORTED_YJS_VERSION is published to TypeScript as the literal type "14.0.0-rc.29", while flow/LexicalY.js.flow declares it as string. Annotating it : string, as LEXICAL_VERSION is in packages/lexical-internal/src/version.ts, keeps the public type stable across bumps and in line with the Flow declaration (AGENTS.md asks for both type systems to be maintained). The replacement pattern in scripts/updateVersion.mjs would need to accept the annotation.
  3. Test plan. The ### Before block shows 23 tests and older test names, while YjsContract.test.ts now has 41. AGENTS.md asks for the real Before and After output of the command that was run; re-running it at this head with the guard calls removed (YDoc.ts:65, Binding.ts:940, :941, :951) gives 32 failed | 9 passed (41), which would be an accurate replacement.

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.

* @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';

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: this publishes export declare const SUPPORTED_YJS_VERSION = "14.0.0-rc.29";, a literal type, while flow/LexicalY.js.flow declares string. export const SUPPORTED_YJS_VERSION: string = '…', as LEXICAL_VERSION does, keeps the TypeScript type stable across bumps; the pattern in scripts/updateVersion.mjs would need to match the annotation.

This branch was successfully deployed

2 active deployments
Preview – lexical — 906f6a39 Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — 906f6a39 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant