Skip to content

[lexical-y] Chore: Test that internal writes of node attributes go through Seen - #9550

Merged
zurfyx merged 2 commits into
mainfrom
zurfyx/y-seen-writes-guard
Oct 30, 2026
Merged

zurfyx merged 2 commits into
mainfrom
zurfyx/y-seen-writes-guard

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Description

Stacked on #9533 (base branch zurfyx/y-seen-view; one module for what a binding's editor sees of Yjs).

A binding must write node attributes through Seen's setAttr and deleteAttr: while a remote change waits to be read, they write where the binding's editor sees the document. A raw y.setAttr(...) writes where Yjs has it now and brings back what that change deleted. Misses of this kind were found by review twice before (the N2 and G-4 items of the integration review); nothing enforced the rule.

This adds a syntactic guard, a source test next to the existing process.env one. It parses each internal module except Seen.ts with the TypeScript AST and lists every use of a Y.Node method that writes attributes (setAttr, deleteAttr, clearAttrs, applyDelta; property or string-literal element access, called or only referenced as in a bind, or destructured), and every call shaped like Y.Node's delete(index, length) or format(index, length, attrs), each by the function it is in and its first argument. The only ones allowed are the five writes of the root's records (the layout and schema versions written, a type's pinned storage), listed by call site and key: they are not content the editor shows. Comments and strings do not hide or invent a match.

It is not exhaustive. Not covered: Item.delete(tr) (a binding deletes items through deleteItem) and a one-argument Y.Node#delete(index), which a syntactic scan cannot tell from a Map or Set delete; aliases of delete and format; computed method names; writes reached through other modules' helpers; and reads, which the reader of remote changes and scans of what each client wrote do raw on purpose.

Changeset

None: test only.

Test plan

Before

With a scratch module internal/ZZProbe.ts of raw writes (optional calls, bracket access, a bound alias, comment and string tricks, clearAttrs, applyDelta, delete(0, 1), format(0, 1, …); not committed; a second probe with destructured and parenthesized forms is caught the same way):

npx vitest run --project unit packages/lexical-y/src/__tests__/unit/Source.test.ts
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.setAttr((not called))",
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.deleteAttr('x')",
+   "ZZProbe.ts probe: node.setAttr('y')",
+   "ZZProbe.ts probe: node.clearAttrs()",
+   "ZZProbe.ts probe: node.applyDelta(node.change['setAttr']('x', 'new'))",
+   "ZZProbe.ts probe: node.change.setAttr('x')",
+   "ZZProbe.ts probe: node.delete(0)",
+   "ZZProbe.ts probe: node.format(0)",
      Tests  1 failed | 3 passed (4)

After

On this branch as committed:

npx vitest run --project unit packages/lexical-y/src/__tests__/unit/Source.test.ts
      Tests  4 passed (4)

pnpm run ci-check: Found 0 errors.

@vercel

vercel Bot commented Oct 29, 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:25pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 8:25pm 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 29, 2026
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: e1963c5

@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 reports exactly the five root-record writes at this head and fails when an internal module writes an attribute raw (for example y.setAttr(attr, slotY) in place of setAttr(y, attr, slotY, writer.tr) from Seen in Slots.ts). The suggestions below are optional and non-blocking.

  1. Report the three-argument insert (packages/lexical-y/src/__tests__/unit/Source.test.ts, lines 116–120). In @y/y 14.0.0-rc.29, Y.Node#insert(index, content, formats) writes formats at an index counted in the document as Yjs has it now, just like the covered format(index, length, formats) (both apply a delta that starts with retain(index)), but it isn't reported and isn't in the description's not-covered list. The binding's own Cursor#insert(content, formats) takes two arguments (packages/lexical-y/src/internal/Cursor.ts, line 686), so adding (method === 'insert' && args !== null && args.length === 3) reports nothing today. The two-argument form and push/unshift can't be told apart syntactically and could join the not-covered list.
  2. Name the limits in the comment (lines 45–55). The not-covered list lives only in the commit message, and a few forms are in neither: a destructuring assignment ({setAttr: f} = y) (the declaration const {setAttr} = y is reported), a delete or format callee wrapped in ! or as (the unwrapping at lines 107–109 steps through parentheses only), and a raw new Item(…, y, key, content).integrate(tr, 0), which is how Seen.setAttr itself writes. One sentence there keeps a green run from reading as proof that no raw write remains.
  3. Keep entries whitespace-stable (line 129). args[0].getText(file) keeps a wrapped argument's line break and indentation, so a reformat alone could change an expected entry; args[0].getText(file).replace(/\s+/g, ' ') avoids that.
  4. Scan every non-test module (lines 63–66). Only top-level src/internal/*.ts is parsed; walking src recursively, as the process.env test above does, would also cover a future subdirectory and the two entry modules, and reports nothing more today.

No changeset is needed for a test-only change (CONTRIBUTING.md, Changesets section).

Comment on lines +45 to +55
// A binding writes node attributes through Seen (setAttr, deleteAttr): while
// a remote change waits to be read, those write where the binding's editor
// sees the document. Written raw, an attribute is set where Yjs has it now,
// and the write brings back what that change deleted. The root's records
// (the layout and schema versions written, and the pinned storage of a
// type) are written raw: they are not content the editor shows. The scan is
// syntactic (the TypeScript AST of each internal module): it lists every
// use of a Y.Node method that writes attributes (`setAttr`, `deleteAttr`,
// `clearAttrs`, `applyDelta`), called or not, and every call shaped like
// Y.Node's `delete(index, length)` or `format(index, length, attrs)`, by
// the function it is in and its first argument.

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 says what the scan reports but not what it can't see; the not-covered list is only in the commit message. A sentence here (plus forms that list omits: a destructuring assignment ({setAttr: f} = y), a delete/format callee wrapped in ! or as, and a raw new Item(…).integrate(tr, 0), which is how Seen.setAttr writes) keeps a green run from reading as proof that no raw write remains.

Comment on lines +63 to +66
const dir = path.resolve(__dirname, '../../internal');
const found: string[] = [];
for (const name of fs.readdirSync(dir).sort()) {
if (!name.endsWith('.ts') || name === 'Seen.ts') {

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: only top-level src/internal/*.ts is parsed, so a module in a future subdirectory of internal/ (or a write in src/headless.ts / src/index.ts) is never scanned. Walking every non-test module under src, as the process.env test above does, reports nothing more today.

Comment on lines +118 to +119
(method === 'delete' && args !== null && args.length === 2) ||
(method === 'format' && args !== null && args.length === 3)

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: Y.Node#insert(index, content, formats) writes formats at a Yjs index just like format(index, length, formats) (in @y/y 14.0.0-rc.29 both apply a delta that starts with retain(index)), but it isn't reported. The binding's Cursor#insert(content, formats) takes two arguments, so a three-argument rule reports nothing today:

(method === 'insert' && args !== null && args.length === 3) ||

The two-argument form and push/unshift can't be told apart syntactically; they could join the not-covered list.

? '(not called)'
: args.length === 0
? ''
: args[0].getText(file);

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: getText keeps a wrapped first argument's line break and indentation, so an allowed write whose key Prettier wraps would need them in its expected entry, and a reformat alone would fail the test. args[0].getText(file).replace(/\s+/g, ' ') keeps entries stable.

…rough Seen

## Description

Stacked on #9533 (base branch `zurfyx/y-seen-view`; one module for what a binding's editor sees of Yjs).

A binding must write node attributes through `Seen`'s `setAttr` and `deleteAttr`: while a remote change waits to be read, they write where the binding's editor sees the document. A raw `y.setAttr(...)` writes where Yjs has it now and brings back what that change deleted. Misses of this kind were found by review twice before (the N2 and G-4 items of the integration review); nothing enforced the rule.

This adds a syntactic guard, a source test next to the existing `process.env` one. It parses each internal module except `Seen.ts` with the TypeScript AST and lists every use of a `Y.Node` method that writes attributes (`setAttr`, `deleteAttr`, `clearAttrs`, `applyDelta`; property or string-literal element access, called or only referenced as in a `bind`, or destructured), and every call shaped like `Y.Node`'s `delete(index, length)` or `format(index, length, attrs)`, each by the function it is in and its first argument. The only ones allowed are the five writes of the root's records (the layout and schema versions written, a type's pinned storage), listed by call site and key: they are not content the editor shows. Comments and strings do not hide or invent a match.

It is not exhaustive. Not covered: `Item.delete(tr)` (a binding deletes items through `deleteItem`) and a one-argument `Y.Node#delete(index)`, which a syntactic scan cannot tell from a `Map` or `Set` delete; aliases of `delete` and `format`; computed method names; writes reached through other modules' helpers; and reads, which the reader of remote changes and scans of what each client wrote do raw on purpose.

## Changeset

None: test only.

## Test plan

### Before

With a scratch module `internal/ZZProbe.ts` of raw writes (optional calls, bracket access, a bound alias, comment and string tricks, `clearAttrs`, `applyDelta`, `delete(0, 1)`, `format(0, 1, …)`; not committed; a second probe with destructured and parenthesized forms is caught the same way):

```
npx vitest run --project unit packages/lexical-y/src/__tests__/unit/Source.test.ts
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.setAttr((not called))",
+   "ZZProbe.ts probe: node.setAttr('x')",
+   "ZZProbe.ts probe: node.deleteAttr('x')",
+   "ZZProbe.ts probe: node.setAttr('y')",
+   "ZZProbe.ts probe: node.clearAttrs()",
+   "ZZProbe.ts probe: node.applyDelta(node.change['setAttr']('x', 'new'))",
+   "ZZProbe.ts probe: node.change.setAttr('x')",
+   "ZZProbe.ts probe: node.delete(0)",
+   "ZZProbe.ts probe: node.format(0)",
      Tests  1 failed | 3 passed (4)
```

### After

On this branch as committed:

```
npx vitest run --project unit packages/lexical-y/src/__tests__/unit/Source.test.ts
      Tests  4 passed (4)
```

`pnpm run ci-check`: `Found 0 errors`.
## Description

This commit changes no file. The repository's review bot reviews a pull request when its branch is pushed; this branch was last pushed before the bot started reviewing the repository, so it had no review yet.

@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 does what the description says: it passes on this branch, reports each of the listed shapes once on a scratch module (optional calls, bracket and template-literal access, bound aliases, parentheses, destructuring, clearAttrs, applyDelta, two-argument delete, three-argument format), ignores comments and strings, and, run on the parent of #9523, reports the nine raw setAttr/deleteAttr calls that change moved behind the view-aware helpers.

One optional, non-blocking suggestion:

  • Two attribute-writing forms pass the scan and are not in the "Not covered" list (packages/lexical-y/src/__tests__/unit/Source.test.ts, lines 45-55 and 116-120). In @y/y 14.0.0-rc.29, Y.Node#insert(index, content, format) writes formatting attributes through the same applyDelta path as format(index, length, formats), which the scan already reports; and new Item(id, left, origin, right, rightOrigin, parent, parentSub, content).integrate(tr, 0) with a string parentSub writes an attribute directly, the way Seen.setAttr does (packages/lexical-y/src/internal/Seen.ts, lines 194-203). Adding (method === 'insert' && args !== null && args.length === 3) next to the format rule, and reporting a new Item(...) whose seventh argument is not the literal null, finds nothing new on this branch (Cursor#insert takes two arguments and the other new Item calls pass null), so the list stays at five entries. If you would rather keep the scan as it is, naming these forms, along with positional insert(index, content), push and unshift, in the comment keeps its coverage claim exact.

No changeset is needed: CONTRIBUTING.md says changes with no effect on people who use Lexical, such as tests, need none.

if (
ATTR_WRITES.has(method) ||
(method === 'delete' && args !== null && args.length === 2) ||
(method === 'format' && args !== null && args.length === 3)

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, non-blocking: in @y/y 14.0.0-rc.29, Y.Node#insert(index, content, format) writes formatting attributes through the same applyDelta path as format(index, length, formats), so a three-argument insert could sit beside this rule:

(method === 'insert' && args !== null && args.length === 3)

It reports nothing new on this branch, since Cursor#insert(content, formats) takes two arguments. The other attribute write the scan does not see is a new Item(...) whose seventh argument (parentSub) is a key rather than null, which is how Seen.setAttr writes; if both stay out of scope, the comment above the case could name them as not covered.

Merged via the queue into main with commit 60bd176 Oct 30, 2026
61 of 69 checks passed
zurfyx added a commit that referenced this pull request Oct 30, 2026
## 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.
zurfyx added a commit that referenced this pull request Oct 30, 2026
…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 []
```

This branch was successfully deployed

2 active deployments
Preview – lexical — e1963c53 Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — e1963c53 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