Skip to content

[lexical-website][lexical-y] Documentation Update: Record that a Yjs UndoManager destroyed without a clear keeps what its steps protected (D98) - #9715

Merged
zurfyx merged 1 commit into
mainfrom
zurfyx/y-undo-manager-close-docs
Nov 2, 2026
Merged

zurfyx merged 1 commit into
mainfrom
zurfyx/y-undo-manager-close-docs

Conversation

@zurfyx

@zurfyx zurfyx commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Description

When the binding stops (binding.dispose(), or the cleanup its start returns: an editor unmounted or mounted again), it destroys its Yjs UndoManager without clearing it, and Yjs destroys an UndoManager the same way when its document is destroyed. Yjs's UndoManager#destroy does not clear, so the items its steps kept stay kept until the document reloads: a character deleted in a table cell keeps the cell, its row and its table, and a table a collaborator deletes afterwards stays in that client's document (and in any state it encodes). Measured over 50 cycles per table size with one character deleted and one word bolded in one cell: 108 to 175 bytes per table from a 1x1 to a 4x4 table, the same on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping; more cells edited keep more (about 1.3 KB with one character deleted in every cell of a 4x4 table). Any editor that destroys a Yjs UndoManager without clearing it first keeps the same items.

This was weighed as an accepted limit rather than having the binding clear at stop: Yjs's clear releases with one shared keep flag, so it would also release what another UndoManager on the document (an app's, or a second binding's) still keeps for its own undo, which a later collection could then drop.

  • DECISIONS.md: new row D98 (ACCEPTED LIMIT); D88's "one deleted after the binding stops is collected" now says it holds when the history was cleared before the stop (the same sentence in y.md points to the new item).
  • y.md Limits: what happens, that binding the same document again does not free it, and how to avoid or free it (binding.undoManager?.clear() while the binding is started, before stopping and before the delete, where no other undo manager on the document still needs what it kept; a table deleted while the history held such a step stays after a later clear, as releasing collects nothing; a reload, or a server or relay that loads the state with garbage collection on, compacts it).
  • New regressions/UndoManagerStopWithoutClear.test.ts pins the documented outcome with Yjs garbage collection on: stopped without a clear, the deleted table, row and cell stay in the client's document (and a new document loaded from its state collects them); cleared before stopping, none stay; cleared only after the table was deleted, they stay.

Test plan

Before

No test pinned the outcome. The new test's oracle rejects the alternative the row weighs: with the binding's stop changed to clear its undo manager before destroying it (local experiment, not part of this change), the "without a clear" cell fails:

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts
   × a table a collaborator deletes stays in B’s document when B stopped without a clear (gc on) 80ms
      Tests  1 failed | 2 passed (3)

After

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts
      Tests  3 passed (3)
$ LEXICAL_Y_GC=1 ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts
      Tests  3 passed (3)

tsc -p tsconfig.test.json and eslint on the new file pass. No source change.

…UndoManager destroyed without a clear keeps what its steps protected (D98)

## Description

When the binding stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), it destroys its Yjs `UndoManager` without clearing it, and Yjs destroys an `UndoManager` the same way when its document is destroyed. Yjs's `UndoManager#destroy` does not clear, so the items its steps kept stay kept until the document reloads: a character deleted in a table cell keeps the cell, its row and its table, and a table a collaborator deletes afterwards stays in that client's document (and in any state it encodes). Measured over 50 cycles per table size with one character deleted and one word bolded in one cell: 108 to 175 bytes per table from a 1x1 to a 4x4 table, the same on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping; more cells edited keep more (about 1.3 KB with one character deleted in every cell of a 4x4 table). Any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items.

This was weighed as an accepted limit rather than having the binding clear at stop: Yjs's clear releases with one shared keep flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, which a later collection could then drop.

- `DECISIONS.md`: new row D98 (`ACCEPTED LIMIT`); D88's "one deleted after the binding stops is collected" now says it holds when the history was cleared before the stop (the same sentence in y.md points to the new item).
- `y.md` Limits: what happens, that binding the same document again does not free it, and how to avoid or free it (`binding.undoManager?.clear()` while the binding is started, before stopping and before the delete, where no other undo manager on the document still needs what it kept; a table deleted while the history held such a step stays after a later clear, as releasing collects nothing; a reload, or a server or relay that loads the state with garbage collection on, compacts it).
- New `regressions/UndoManagerStopWithoutClear.test.ts` pins the documented outcome with Yjs garbage collection on: stopped without a clear, the deleted table, row and cell stay in the client's document (and a new document loaded from its state collects them); cleared before stopping, none stay; cleared only after the table was deleted, they stay.

## Test plan

### Before

No test pinned the outcome. The new test's oracle rejects the alternative the row weighs: with the binding's stop changed to clear its undo manager before destroying it (local experiment, not part of this change), the "without a clear" cell fails:

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts
   × a table a collaborator deletes stays in B’s document when B stopped without a clear (gc on) 80ms
      Tests  1 failed | 2 passed (3)
```

### After

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts
      Tests  3 passed (3)
$ LEXICAL_Y_GC=1 ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts
      Tests  3 passed (3)
```

`tsc -p tsconfig.test.json` and `eslint` on the new file pass. No source change.
@vercel

vercel Bot commented Nov 2, 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 Nov 2, 2026 1:44am UTC
lexical-playground Ready Ready Preview Nov 2, 2026 1:44am UTC

Request Review

@meta-code-review

meta-code-review Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 1:55 AM UTC

@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 Nov 2, 2026
@pkg-pr-new

pkg-pr-new Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: cd69741

@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. D98 matches what the binding does: it destroys its UndoManager without clearing it when it stops. The new regression test tells the documented outcome apart from the alternative: it fails if the stop clears first and passes on this branch. A few optional, non-blocking suggestions:

  1. Keep D88 and the previous Limits bullet consistent with D98. The D88 row in packages/lexical-y/DECISIONS.md and packages/lexical-website/docs/collaboration/y.md lines 1186–1187 still say a table deleted after the binding stops "is collected". With D98 that holds only once the undo history was cleared, so the page now reads "collected" and then "stays" in consecutive bullets. Consider qualifying both sentences or pointing them to D98. packages/lexical-y/AGENTS.md asks a pull request that changes a recorded limit to update its row.
  2. binding.undoManager can be null (y.md line 1196). The getter returns UndoManager | null: it is null with captureTimeout: null and after the binding stops. So binding.undoManager.clear() fails strict null checks, and binding.undoManager?.clear(), called while the binding is still started, is the safer form to document.
  3. Say when the remedy is safe. The bullet explains that the binding does not clear at stop because a clear releases what another undo manager on the document keeps. Adding that the app's own call is safe when no other undo manager (the app's, or a second binding's) needs its history would make the advice self-contained.
  4. Test nit: UndoManagerStopWithoutClear.test.ts line 105 checks only that three deleted containers remain. Asserting they are the table, row and cell items, by id, would pin the documented claim exactly.

Docs and tests need no changeset (CONTRIBUTING.md, "Changesets").

@@ -1187,6 +1187,17 @@ not merge as a user might expect:
the binding stops is collected. When the binding stops it lets them go,

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: with D98 below, "one deleted after the binding stops is collected" holds only once the undo history was cleared; a step left on the stack keeps the table, as the next bullet says. Qualifying this sentence (and the same one in D88 in packages/lexical-y/DECISIONS.md), or pointing it to D98, keeps the two bullets from reading "collected" then "stays".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3e57d1e: the y.md sentence now says it is collected when the undo history was cleared before the stop (pointing to the next item), and D88's same sentence says so too and points to D98.

a character deleted in a table cell keeps the cell, its row and its
table. A table a collaborator deletes later then stays in that client's
document, and in any state it encodes, until it reloads: about 110 to
175 bytes per table. Call `binding.undoManager.clear()` before stopping,

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: binding.undoManager is typed UndoManager | null (null with captureTimeout: null, and after the binding stops), so binding.undoManager?.clear(), called while the binding is still started, is the form that type-checks. Saying the call is safe when no other undo manager on the document needs its history would also make the remedy self-contained.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3e57d1e: D98 and y.md say binding.undoManager?.clear(), called while the binding is started, before stopping and before the delete, where no other undo manager on the document still needs what it kept (a clear releases that too). They also say a clear after the table was deleted does not free it (releasing collects nothing), with a test cell for that case.

// The table, its row and its cell (the cell's paragraph is a line
// of the cell, not a container of its own). A binding that cleared
// at stop would leave none (checked: 0), at the cost D98 names.
expect(left).toHaveLength(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.

Nit: this passes for any three deleted containers. Capturing the table, row and cell items' ids from B's document before A's delete, and asserting left equals them, would pin the documented "the cell, its row and its table" exactly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3e57d1e: the test records the Y types A's delete removes from B's document (the table, row, cell and the cell's line marker) and asserts that the three left behind are among them, and that exactly three stay.

@zurfyx zurfyx changed the title [lexical-website][lexical-y] Documentation Update: Record that a Yjs UndoManager closed without a clear keeps what its steps protected (D98) [lexical-website][lexical-y] Documentation Update: Record that a Yjs UndoManager destroyed without a clear keeps what its steps protected (D98) Nov 2, 2026
@zurfyx
zurfyx force-pushed the zurfyx/y-undo-manager-close-docs branch from 85f4c8f to 3e57d1e Compare November 2, 2026 01:37
@zurfyx
zurfyx force-pushed the zurfyx/y-undo-manager-close-docs branch from 3e57d1e to cd69741 Compare November 2, 2026 01:43

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

Looks good to me. D98 matches what the binding does today: it destroys its UndoManager without a clear when it stops (packages/lexical-y/src/internal/Binding.ts:1362). The new regression test also tells the documented outcome apart from a binding that clears at stop. Two optional, non-blocking suggestions:

  1. The retention depends on edit order (packages/lexical-y/DECISIONS.md, row D98; packages/lexical-website/docs/collaboration/y.md, lines 1194-1196). Yjs's keepItem stops at the first ancestor that is already kept. Suppose the binding already kept the cell for one of its own format items (D88) before the character was deleted. Then the undo step keeps only the character, and the binding's release at stop unkeeps the cell, row and table, so a table deleted later is collected. With the test's table, deleting a character and then bolding a word leaves 3 deleted containers in B; bolding and then deleting leaves 0. Saying "can keep", or naming the order ("where the delete came before any format the binding still keeps in that cell"), would make the row match what a reader measures. The advice to clear before stopping is right in either order. packages/lexical-y/AGENTS.md asks for accepted limits to be documented in y.md's Limits, so it is worth keeping the two precise together.
  2. Make the cleared test cell non-vacuous (packages/lexical-y/src/__tests__/unit/regressions/UndoManagerStopWithoutClear.test.ts, line 142). The before mode asserts only that left is empty. If A's delete ever stopped reaching B, that cell would still pass. Moving expect(removed).toHaveLength(3) above the if makes every cell prove the table was deleted first.

No changeset is needed: AGENTS.md says tests and website changes need none, and the decision register is not part of the published package.

Comment on lines +1194 to +1196
clearing it, as Yjs does when a document is destroyed, and Yjs keeps what
that manager's steps protected: a character deleted in a table cell keeps
the cell, its row and its table. A table a collaborator deletes afterwards

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 holds when the delete is the first thing to keep the cell. Yjs's keepItem stops at the first ancestor that is already kept, so if the binding kept the cell for one of its own format items (D88) before the character was deleted, the undo step keeps only the character, and the binding's release at stop unkeeps the cell, row and table. With the test's table, bold then delete leaves 0 deleted containers in B, and delete then bold leaves 3. Writing "can keep", or naming the order, would match what a reader measures (the same applies to the D98 row).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch on keepItem stopping at an already kept ancestor. Deferred to a follow-up to keep this head stable for landing: D98 and y.md will say the table can stay (when the delete was the first thing to keep the cell, before any format the binding still keeps there), with a cell for the bold-then-delete order.

}
const left = deletedContainers(t.b.doc);
if (clear === 'before') {
expect(left).toEqual([]);

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: this cell passes even if A's delete never reached B. Moving expect(removed).toHaveLength(3) above the if makes every mode prove the table was deleted first.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed; deferred to the same follow-up (assert the delete reached B in every mode).

@zurfyx
zurfyx added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit d888ed4 Nov 2, 2026
101 of 102 checks passed

This branch was successfully deployed

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