Skip to content

[lexical-y] Refactor: One module answers what a binding's editor sees of Yjs - #9533

Open
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-seen-view
Open

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-seen-view

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026

Copy link
Copy Markdown
Member

Description

What a binding's editor shows of the document is Yjs minus two things: the remote changes it has not read yet (the Unread view, used while it writes a local update made before them) and the items held back while the local editor composes (Hidden). The questions about it were spread over three modules: isGone, attrOf, attrsOf, attrsBefore and the write helpers lived in Unread.ts, isShown, shownLength and shownText in Hidden.ts, and isShownEnd, shownAttrs and prevLiveEnd in State.ts. Several call sites also combined the hold by hand (hidden === null || !hidden.hides(it), hidden === null ? it.length : hidden.shownLength(it)).

This moves all of them, unchanged, into a new internal module, Seen.ts, whose header states what the editor sees and which helper answers what. Unread.ts keeps the view itself (seenBy, emptyView, and viewOf, inserted, deleted for Seen), and Hidden.ts keeps the hold. Two small helpers name the hand-written forms: isHeld(item, hidden) and unheldLength(item, hidden). Every reader and writer now imports these from one place.

No behavior changes: the moved functions are the same code (compared token by token with comments stripped), and each substituted expression is the same expression. The raw item.deleted reads in Cursor's run-opener scans (liveItemBefore, runOpener, restatedEnd) are left as they are: reading them through the unread view writes different bytes when a local format change is written while a collaborator's format change is still unread, so that switch is a behavior change for its own PR.

Changeset

None: internal refactor, no public API or behavior change.

Test plan

Every gate compares this branch with main (0823b74e) on the same machine.

Before

main: golden corpus recorded (below), full unit suite as listed under After, and the same extended fuzz runs.

After

Stored bytes over fuzz seeds: a scratch recorder (not committed) runs fuzz scripts and hashes, per seed and peer, the Yjs state bytes, the editor JSON, the warnings, the selection and the node map.

bid: 1000 seeds, 0 differ
bidmoves: 600 seeds, 0 differ
gc: 1000 seeds, 0 differ
keys: 600 seeds, 0 differ
long: 400 seeds, 0 differ
moves: 1500 seeds, 0 differ
nomove: 3000 seeds, 0 differ
GOLD IDENTICAL

Stored bytes over the test corpus: every @lexical/y unit test (the saved fuzz scripts in __tests__/fuzz/ included) run with a setup file that gives each Y.Doc a deterministic client id and hashes its state bytes after the test.

tests: 7286; stable across base runs: 7047 (7024 with docs, 67564 docs); unstable: 239
new run: 7286 tests; missing from new: 0; stable tests whose bytes differ: 0
CAP IDENTICAL

pnpm run ci-check:

Changeset check passed: 30 pending changeset(s) valid.
Found 0 errors

vitest --project unit --project scripts-unit (36 workers), per test against main:

base {'passed': 18179, 'skipped': 6, 'failed': 1}
new  {'passed': 18179, 'skipped': 7, 'failed': 2}
missing 0, added 2, status changed 1
  CHANGED passed -> failed FollowUpCheckCost.test.ts (timing threshold)
  ADDED passed treeShakingSource.test.ts :: ... @lexical/y (packages/lexical-y/src/internal/Seen.ts)

The one failure on each side is a timing or heap threshold under 36 parallel workers (DisposeMemory on main, FollowUpCheckCost here); run alone, both pass:

npx vitest run --project unit DisposeMemory.test.ts FollowUpCheckCost.test.ts Cursor.test.ts CompositionHoldBack.test.ts
 Test Files  4 passed (4)
      Tests  1827 passed | 2 skipped (1829)

Extended fuzz, same results as main: Fuzz.test.ts seeds 1 to 3000 (gc 1000) and 1 to 1000 with block ids, ConcurrentShapesFuzz 600 seeds, FollowUpFuzz 1000 seeds, NestedFuzz, SlotsFuzz, BlockIdFuzzSeeds and FuzzReplay pass. Seed 2277 fails with dup on main and on this branch alike (outside the default 300 seeds; not caused by this change).

Browser and e2e tests were not run: no code path changes.

… of Yjs

## Description

What a binding's editor shows of the document is Yjs minus two things: the remote changes it has not read yet (the `Unread` view, used while it writes a local update made before them) and the items held back while the local editor composes (`Hidden`). The questions about it were spread over three modules: `isGone`, `attrOf`, `attrsOf`, `attrsBefore` and the write helpers lived in `Unread.ts`, `isShown`, `shownLength` and `shownText` in `Hidden.ts`, and `isShownEnd`, `shownAttrs` and `prevLiveEnd` in `State.ts`. Several call sites also combined the hold by hand (`hidden === null || !hidden.hides(it)`, `hidden === null ? it.length : hidden.shownLength(it)`).

This moves all of them, unchanged, into a new internal module, `Seen.ts`, whose header states what the editor sees and which helper answers what. `Unread.ts` keeps the view itself (`seenBy`, `emptyView`, and `viewOf`, `inserted`, `deleted` for `Seen`), and `Hidden.ts` keeps the hold. Two small helpers name the hand-written forms: `isHeld(item, hidden)` and `unheldLength(item, hidden)`. Every reader and writer now imports these from one place.

No behavior changes: the moved functions are the same code (compared token by token with comments stripped), and each substituted expression is the same expression. The raw `item.deleted` reads in `Cursor`'s run-opener scans (`liveItemBefore`, `runOpener`, `restatedEnd`) are left as they are: reading them through the unread view writes different bytes when a local format change is written while a collaborator's format change is still unread, so that switch is a behavior change for its own PR.

## Changeset

None: internal refactor, no public API or behavior change.

## Test plan

Every gate compares this branch with `main` (`0823b74e`) on the same machine.

### Before

`main`: golden corpus recorded (below), full unit suite as listed under After, and the same extended fuzz runs.

### After

Stored bytes over fuzz seeds: a scratch recorder (not committed) runs fuzz scripts and hashes, per seed and peer, the Yjs state bytes, the editor JSON, the warnings, the selection and the node map.

```
bid: 1000 seeds, 0 differ
bidmoves: 600 seeds, 0 differ
gc: 1000 seeds, 0 differ
keys: 600 seeds, 0 differ
long: 400 seeds, 0 differ
moves: 1500 seeds, 0 differ
nomove: 3000 seeds, 0 differ
GOLD IDENTICAL
```

Stored bytes over the test corpus: every `@lexical/y` unit test (the saved fuzz scripts in `__tests__/fuzz/` included) run with a setup file that gives each `Y.Doc` a deterministic client id and hashes its state bytes after the test.

```
tests: 7286; stable across base runs: 7047 (7024 with docs, 67564 docs); unstable: 239
new run: 7286 tests; missing from new: 0; stable tests whose bytes differ: 0
CAP IDENTICAL
```

`pnpm run ci-check`:

```
Changeset check passed: 30 pending changeset(s) valid.
Found 0 errors
```

`vitest --project unit --project scripts-unit` (36 workers), per test against `main`:

```
base {'passed': 18179, 'skipped': 6, 'failed': 1}
new  {'passed': 18179, 'skipped': 7, 'failed': 2}
missing 0, added 2, status changed 1
  CHANGED passed -> failed FollowUpCheckCost.test.ts (timing threshold)
  ADDED passed treeShakingSource.test.ts :: ... @lexical/y (packages/lexical-y/src/internal/Seen.ts)
```

The one failure on each side is a timing or heap threshold under 36 parallel workers (`DisposeMemory` on `main`, `FollowUpCheckCost` here); run alone, both pass:

```
npx vitest run --project unit DisposeMemory.test.ts FollowUpCheckCost.test.ts Cursor.test.ts CompositionHoldBack.test.ts
 Test Files  4 passed (4)
      Tests  1827 passed | 2 skipped (1829)
```

Extended fuzz, same results as `main`: `Fuzz.test.ts` seeds 1 to 3000 (gc 1000) and 1 to 1000 with block ids, `ConcurrentShapesFuzz` 600 seeds, `FollowUpFuzz` 1000 seeds, `NestedFuzz`, `SlotsFuzz`, `BlockIdFuzzSeeds` and `FuzzReplay` pass. Seed 2277 fails with `dup` on `main` and on this branch alike (outside the default 300 seeds; not caused by this change).

Browser and e2e tests were not run: no code path changes.
@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 29, 2026 10:30pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 10:30pm 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@9533

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: f01ad47

zurfyx added a commit that referenced this pull request Oct 29, 2026
…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 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`), 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.

Not covered: `Item.delete(tr)` (a binding deletes items through `deleteItem`), which a syntactic scan cannot tell from a `Map` or `Set` delete; reads, which the reader of remote changes and scans of what each client wrote do raw on purpose; and writes reached through computed names or other modules' helpers.

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

```
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`.
zurfyx added a commit that referenced this pull request Oct 29, 2026
…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`.

This branch was successfully deployed

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