Repository navigation
[lexical-y][lexical-website] Feature: Refuse to run against a Yjs other than the one @lexical/y supports - #9607
Conversation
…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).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
…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.
|
AI review status for this pull request.
|
…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)
```
There was a problem hiding this comment.
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:
- Record the guard in the decision register (optional). On
main,packages/lexical-y/DECISIONS.mdrow D14 still says “A runtime guard on the Yjs version is a follow-up”, andpackages/lexical-y/AGENTS.mdasks a pull request that makes or changes a recorded decision to update its row in the same pull request and to list accepted limits iny.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 (likeYCollabExtension's existing build-time invariants, rather than D07's read-only warning path, so a mismatched@y/ystops the editor from building), and listing its limits (a shape-compatible@y/ypasses; a provider that bundles its own Yjs is not caught) under## Limitsinpackages/lexical-website/docs/collaboration/y.md. - Refresh the test plan output (optional). The
### Before(23 tests) and### After(41 tests acrossYjsContract.test.tsandSource.test.ts) blocks appear to come from an earlier revision;YjsContract.test.tsnow has 41 tests on its own. AGENTS.md (Commits and Pull Requests) asks for the real output of the command as run. - README wording (nit). In
packages/lexical-y/README.mdline 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.
| 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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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:
- Changeset summary (
.changeset/lexical-y-yjs-version-guard.md, line 5). It reads as an exact-version check, but the probe checks shape:@y/y14.0.0-rc.25, rc.26 and rc.28 pass it unchanged, and rc.24 and earlier are refused because they lack theNodeexport. 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 writtenwould match. The PR title uses the same wording. - The missing-export case in the docs (
packages/lexical-website/docs/collaboration/y.md, Install, and the install note inpackages/lexical-y/README.md). For npm'slatest(14.0.0-rc.7) through rc.24 the binding'sNodeimport does not exist, so an app stops before the check runs: an esbuild build fails withNo matching export … for import "Node"and native ES modules throwSyntaxError: 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. - Test plan Before block. The pasted
18 failed | 5 passed (23)predates the currentYjsContract.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. - 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 |
There was a problem hiding this comment.
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
| 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: |
There was a problem hiding this comment.
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 [] ```
There was a problem hiding this comment.
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:
packages/lexical-y/README.md, "Bumping@y/y", step 1 (line 119).packages/lexical-playground/package.jsonpins@y/yon its own, and nothing inpnpm-workspace.yamlties it to this package's pin. Following the checklist as written would leave the playground on the old version, so its document (fromcreateYDoc) and@y/websocketwould resolve different copies of Yjs, which is the provider case D79 says the guard cannot detect. Consider naming the playground pin in step 1.packages/lexical-y/src/internal/SupportedYjs.ts:17.SUPPORTED_YJS_VERSIONis published to TypeScript as the literal type"14.0.0-rc.29", whileflow/LexicalY.js.flowdeclares it asstring. Annotating it: string, asLEXICAL_VERSIONis inpackages/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 inscripts/updateVersion.mjswould need to accept the annotation.- Test plan. The
### Beforeblock shows 23 tests and older test names, whileYjsContract.test.tsnow 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) gives32 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`, |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
Description
@lexical/yis coupled to one exact Yjs version on purpose (the register'sD13/D14): it writes through its own cursor on Yjs items and reads struct
fields that are not public API.
package.jsonalready pins@y/y14.0.0-rc.29exactly as a peer dependency, but a package manager onlywarns 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 getsilent misbehaviour.
This adds a fail-closed runtime guard:
@y/yexposes no version at run time (no export,constant or
Docfield; its duplicate-import flagglobalThis['__ $YJS14$ __']is onlytrue; itspackage.jsonsubpathneeds 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.24and earlier fail atbuild or load (no
Nodeexport),rc.25,rc.26andrc.28pass theprobe.
src/internal/YjsContract.tslists every@y/yexport 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).
probeYjschecks them on two scratch documents (fixed guid, soeach 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/yin another realm (Maps by brand, bytes made by@y/y)and in minified builds. Members are checked by property reads typed against
@y/y'sdeclarations 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 thedeclarations 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.update or per document), from
createYDoc(before the document is madeor the state is applied), the
YBindingconstructor (soYCollabExtension, whosebuildmakes the binding before any extensionregisters), and through
createYDocevery headless helper, which nowmakes 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
YBindingconstructor also refuses, on every call, aY.Docor rootthat is not an instance of the
@y/yit loaded (a second copy, whoseitems fail the binding's
instanceofchecks).YUnsupportedYjsError(code: UNSUPPORTED_YJS_ERROR,'@lexical/y/unsupported-yjs') withdata: {supported, found, reason},reason'changed-internals'or'other-copy', and a short messagethat 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/yand@lexical/y/headless, typed in TypeScript and Flow. Not aninvariant,so production builds keep the message and data.
SUPPORTED_YJS_VERSION(exported) lives insrc/internal/SupportedYjs.ts, a literal generated from the package'speerDependencies['@y/y']byscripts/updateVersion.mjs(
pnpm run update-version), like theLEXICAL_VERSIONfallback 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 thatcompile the
sourceexport condition.YjsContract.test.tsderives, 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/ytype,YDocand inheritedlib0members included, destructured fields, optionproperties and
on/off/onceevent names) and fails when it differsfrom the lists in either direction, naming the file.
@y/y'sdeclarations name types they do not import (
Transaction#doc: Doc,RelativePosition#item: ID | null, the reason apps needskipLibCheck), so the test declares those names as globals in avirtual file (test only) and fails on any remaining
anyvalue that comesfrom
@y/y(a member it declares, a call of its method, an untyped localor callback parameter). A new use of a Yjs internal lands with its probe.
@y/yfor 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 aconsole.error; it says nothing aboutversions. 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 noruntime check, although y-prosemirror also reads internals
(
_item,store.clients).Known limits:
@y/ythat provides the same exports, members and eventspasses, 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.
@y/ywithout one of the exportsthe binding imports fails to link with a
SyntaxErrornaming the export,before any code runs: fail-closed too, though not with the typed error.
The probe imports nothing the binding does not.
that seeds Yjs's randomness sees later random ids shift (the tp2 soak
did;
y.mdsays to give such documents explicit client ids).app's document with it (the
@y/websocketlatestcasey.mdalreadywarns 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.mdkeeps itsnpm lsadvice for it.Decision register: D14 now records the guard (no opt-out, unlike D07's
read-only warning for remote data); new
ACCEPTED LIMITD79 for what itcannot tell apart, also in
y.md's Limits.Docs:
y.mdInstall says@lexical/ysupports 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, runupdate-version, thecontract 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 (
createYDocand the
YBindingconstructor):The drift test, with a use of two
Docfields added to a source file andnot to the contract:
After
Cases covered by
YjsContract.test.ts: the supported version equals thepeer pin, the dev dependency and the installed
@y/y; the contract equalsthe derived uses; the installed
@y/ypasses; it passes from another realmand with every content class named alike (a minified build), where an
unusable attribute content is still caught; and, with
@y/ymocked to amissing export, an emptied method, a null field, a missing member, a
renamed field the probe assigns and a missing event,
createYDoc(withand without a state),
YBinding,YCollabExtension,seedYState,readYStateandeditYStateeach throw the typed error withdata, thedocument'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/yevery 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, theheadless re-export is the same class).
(Counts on the branch merged with
mainat be5afea88816, which includes#9550 and #9559:
YjsContract.tsis excluded from #9550's Seen guard, asit writes only to its own scratch documents, and #9559's
encodeStateVectoris 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-
Nodebuild error) are in the commits since. Downstreamsuites and a soak on main + this PR: no difference attributable to it.