Skip to content

[lexical-y] Bug Fix: Keep list numbering right after several follow-ups in a row that are read again - #9634

Draft
zurfyx wants to merge 2 commits into
mainfrom
zurfyx/reread-refresh-count
Draft

zurfyx wants to merge 2 commits into
mainfrom
zurfyx/reread-refresh-count

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Parked (10/30)

Draft until a maintainer picks one of the options below. Three requirements turned out to conflict:

On main, (c) stops only because the fourth re-read in a row leaves the value stale. Main gets (b) by violating (a). As long as each round leaves the value fresh, an app that answers that value with a refused follow-up keeps being answered, whatever the binding counts. The only ways to end that chain are to stop re-reading (the editor then shows content that is not stored) or to show a stale value again (#9632). This head (2c4a89b) satisfies (a) and (b) for replies that do not read the value. It does not stop (c): nested-update, microtask and setTimeout replies each run until the test's fuse, while main and the nightly stop after 4 or 5 rounds.

Options:

  1. Keep main's behavior (recommended). Record Bug: [lexical-y] After refused naming follow-ups, the author shows a number list's wrapper item with value 1 where its own document reads 2 #9632 as an accepted limit: after four refused follow-ups in a row with no user edit, derived values (list numbering) can lag until the next edit. The repro sets go in as test.fails naming Bug: [lexical-y] After refused naming follow-ups, the author shows a number list's wrapper item with value 1 where its own document reads 2 #9632.
  2. Take this head. Fresh values everywhere. Document an app rule: do not answer the binding's commits with follow-ups it does not write, or an answer that depends on a derived value can loop.
  3. Fail closed after N re-reads in a row. The binding goes read only with a typed warning. A larger design change.

Description

A follow-up update (BlockIdExtension's naming, the update that runs a newly registered transform) may only set attributes or delete. Anything else it changes is not written and is read again, and the re-read refreshes the derived fields of the groups it rebuilds (list numbering), as every read does.

To stop a loop (a group's own transform that rewrites the same content on every refresh, or an app's listener that answers every refresh with such a follow-up), the binding counts re-reads in a row until a user's edit, and the fourth one ran no refresh at all. So four follow-ups in a row, each read again, left the author with a stale derived value: a number list's item holding a nested list showed value 1, where its own fresh read, its peers and Lexical's numbering showed 2. It stayed wrong after later syncs and edits.

The count is unchanged. The last re-read now runs the refresh in its own update, with no refresh commit after it:

  • The editor shows the numbering a fresh reader shows.
  • Nothing that answers a refresh runs again (a listener keyed on the refresh, a group's own transform), so the loop is cut off as before.
  • A group whose transforms change more than its derived fields is read again at once, in the same update, so the editor shows the document.

(An earlier head of this pull request counted only re-reads of the binding's own refresh. Review showed that an app's listener answering each refresh with a refused follow-up then looped without end, so that was replaced.)

No decision row: this keeps the existing rules that every read refreshes derived fields and that the loop is cut off after the same number of re-reads.

Closes #9632

Changeset

lexical-y-reread-refresh.md (@lexical/y, fix): Keep list numbering right on an editor after several follow-up updates in a row that the binding reads again.

Test plan

New regression tests in packages/lexical-y/src/__tests__/unit/regressions/:

  • RefusedFollowUpRefresh.test.ts (25 cells):
    • 0, 1, 2, 3, 4 or 6 follow-ups in a row that each insert a paragraph (read again), then one that empties the list item holding a nested list. Tagged as BlockIdExtension's naming and untagged, both client id orders. After each, the author shows what a fresh reader shows, and its peer matches.
    • A list whose own transform adds an item every time it runs. It runs 5 times with one warning, and the editor equals a fresh reader.
  • RefusedFollowUpFreshness.test.ts (32 cells): follow-ups in a row (0, 3, 6, 12), with the numbering checked synchronously after each update. Discrete and batched, with and without BlockIdExtension, lists starting at 1 and 7.
  • RefusedFollowUpLoops.test.ts (16 cells): an app listener that answers the refresh, or collaboration-tagged commits, with a refused follow-up. On one or both peers, discrete and batched, both client id orders. The loop stops.
  • RefusedFollowUpReadOnly.test.ts (8 cells): the same on a read-only client, gc on and off. Nothing is written and the editor shows the document.

Before

On main (1853707):

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts
 × 3 before, tag block-id-repair, ids 1,2
 × 3 before, tag block-id-repair, ids 2,1
 × 4 before, tag block-id-repair, ids 1,2
 × 4 before, tag block-id-repair, ids 2,1
 × 6 before, tag block-id-repair, ids 1,2
 × 6 before, tag block-id-repair, ids 2,1
AssertionError: empty the item holding the nested list: expected [ 1, 1 ] to deeply equal [ 1, 2 ]
      Tests  6 failed | 19 passed (25)
  • RefusedFollowUpFreshness.test.ts: 24 of 32 cells fail on main.
  • The loop and read-only cells pass on main. They failed on this pull request's earlier head be4f766 (expected 20 to be less than 20, the test's fuse).
  • FollowUpFuzz.test.ts with seeds 1-36,000 (FOLLOWUP_FUZZ_SEEDS, run in ranges): 52 seeds fail on main, every one a derived list value on the author after a refused follow-up. Seeds 1-6,000 fail 11 (1793, 1856, 1982, 2750, 3127, 3316, 4471, 4527, 5014, 5023, 5217). The same seeds fail on the 0.53.1 nightly.

After

On 2c4a89b:

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUp packages/lexical-y/src/__tests__/unit/DisposeMemory.test.ts packages/lexical-y/src/__tests__/unit/Headless.test.ts
 Test Files  6 passed (6)
      Tests  114 passed (114)
$ pnpm exec vitest run --project unit --project scripts-unit
      Tests  25699 passed | 190 expected fail | 135 skipped (26024)
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
      Tests  6083 passed | 29 expected fail | 128 skipped (6240)
$ pnpm run ci-check
Found 0 errors

FollowUpFuzz.test.ts with seeds 1-36,000: 0 fail.

…ps in a row that are read again

## Description

A follow-up update (BlockIdExtension's naming, the update that runs a newly
registered transform) may only set attributes or delete. Anything else it
changes is not written and is read again, and the re-read refreshes the
derived fields of the groups it rebuilds (list numbering), as every read
does.

To stop a group's own transform that changes the same content on every
refresh from looping (refresh, re-read, refresh, ...), the binding counts
re-reads in a row and lets the fourth one run no refresh. It counted every
re-read until a user's edit. So four separate follow-ups in a row, each read
again, made the last re-read skip its refresh. The author then kept a stale
derived value: a number list's item holding a nested list showed `value` 1,
where its own fresh read, its peers and Lexical's numbering showed 2. It
stayed wrong after later syncs and edits.

Now only re-reads of the binding's own refresh are counted. A re-read of any
other update's change starts the count again, as a user's edit already did,
so its refresh always runs. The loop the count guards against is still cut
off after the same number of refreshes.

Closes #9632

## Test plan

`packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts`
(new):
- 0, 1, 2, 3, 4 or 6 follow-ups in a row that each insert a paragraph (read
  again), then one that empties the list item holding a nested list.
  Tagged as BlockIdExtension's naming and untagged, both client id orders.
  After each, the author shows what a fresh reader shows, list numbering
  included, and its peer matches.
- A list whose own transform adds an item every time it runs. The refresh
  is still cut off after 4 runs with one warning, and the editor shows the
  document.

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts
 × 3 before, tag block-id-repair, ids 1,2
 × 3 before, tag block-id-repair, ids 2,1
 × 4 before, tag block-id-repair, ids 1,2
 × 4 before, tag block-id-repair, ids 2,1
 × 6 before, tag block-id-repair, ids 1,2
 × 6 before, tag block-id-repair, ids 2,1
AssertionError: empty the item holding the nested list: expected [ 1, 1 ] to deeply equal [ 1, 2 ]
      Tests  6 failed | 19 passed (25)
```

`FollowUpFuzz.test.ts` with seeds 1-36,000 (`FOLLOWUP_FUZZ_SEEDS`, run in
ranges): 52 seeds fail on main, every one a derived list `value` on the
author after a refused follow-up.

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts
      Tests  25 passed (25)
$ pnpm exec vitest run --project unit packages/lexical-y
      Tests  14593 passed | 190 expected fail | 130 skipped (14913)
```

`FollowUpFuzz.test.ts` with seeds 1-36,000: 0 fail.
@vercel

vercel Bot commented Oct 31, 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 31, 2026 1:01am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 1:01am 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 31, 2026
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 2c4a89b

@meta-code-review

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

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 1:20 AM UTC

… of counting only refresh re-reads

## Description

The previous commit counted only re-reads of the binding's own refresh
toward `MAX_REREADS`; a re-read of any other update's change started the
count again. An app's update listener that answers each refresh (or each
collaboration-tagged commit) with a follow-up the binding does not write
then looped without end: that reply is read again, the re-read refreshes,
and the refresh sets the listener off again. Main stops it after four
re-reads (review: 24 cells, with active and read-only clients, discrete and
batched updates, one or both peers, both client id orders). Telling such
replies from an app's own follow-ups by timing cannot work either: a
listener can answer in a microtask.

So the count is main's again: re-reads in a row until a user's edit. What
changes is the last re-read. It ran no refresh at all, which left derived
fields stale (#9632). It now runs the refresh in its own update, with no
refresh commit after it. The editor shows the numbering a fresh reader
shows, and nothing that answers a refresh (a listener keyed on the refresh,
a group's own transform) runs again. A group whose transforms change more
than its derived fields (a transform that rewrites the same content every
time) is read again at once, in the same update, so the editor shows the
document.

## Test plan

New, from the review:
- `regressions/RefusedFollowUpLoops.test.ts`: 16 cells, listener on one or
  both peers, triggered by the refresh or by collaboration-tagged commits,
  discrete and batched, both client id orders.
- `regressions/RefusedFollowUpReadOnly.test.ts`: 8 cells on a read-only
  client, `gc` on and off, discrete and batched, both triggers.
- `regressions/RefusedFollowUpFreshness.test.ts`: 32 cells of follow-ups in
  a row (0, 3, 6, 12), the numbering checked synchronously after each
  update, discrete and batched, with and without BlockIdExtension, lists
  starting at 1 and 7.

`RefusedFollowUpRefresh.test.ts`: the transform that adds an item on every
run now runs 5 times (the last re-read's refresh, read again in the same
update), with one warning, and the editor equals a fresh reader.

### Before

On the previous commit (be4f766):

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUp
 × listener refusal cycle ... (16 of 16)
 × read-only refused listener ... (8 of 8)
AssertionError: listener-generated refusal chain must terminate before fuse: expected 20 to be less than 20
AssertionError: refusal chain terminates before harness fuse: expected 20 to be less than 20
```

On main (1853707), the 24 loop cells pass and 24 of the 32 freshness cells
fail (the #9632 numbering).

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUp packages/lexical-y/src/__tests__/unit/DisposeMemory.test.ts packages/lexical-y/src/__tests__/unit/Headless.test.ts
```
 Test Files  6 passed (6)
      Tests  114 passed (114)
$ pnpm exec vitest run --project unit --project scripts-unit
      Tests  25699 passed | 190 expected fail | 135 skipped (26024)
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
      Tests  6083 passed | 29 expected fail | 128 skipped (6240)
$ pnpm run ci-check
Found 0 errors
```

`FollowUpFuzz.test.ts` with seeds 1-36,000: 0 fail (main: 52).

@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 last re-read now refreshes list numbering inside its own update instead of dropping the refresh, which fixes #9632 without reopening the listener loop the earlier head ran into. The new numbering and freshness tests fail on main and pass on this branch, as do the 11 FollowUpFuzz seeds listed in #9632, and the loop and read-only tests keep the re-read cap honest. A few optional, non-blocking suggestions:

  1. Changeset Verify: paragraph (.changeset/lexical-y-reread-refresh.md). CONTRIBUTING.md ("Changesets") and .changeset/README.md ("Behavior changes") ask a fix that changes observable behavior to carry a Verify: paragraph. Here the list item value the author's editor shows changes, so something like:

    Verify: list numbering (ListItemNode.getValue()) on the editor that made several BlockIdExtension naming updates in a row that the binding does not write, compared with a fresh read of the same document.
    
  2. MAX_REREADS JSDoc (packages/lexical-y/src/internal/Binding.ts:276-278). "every re-read leaves the derived fields a fresh reader shows" does not hold for a group whose own transform changes structure. $refreshInLastRead reads that group again and then clears state.refresh, so it keeps numbering computed on the structure that was just reverted. For example, a number list whose $transform prepends an item on every run ends at [2, 3, 4] on the author, against [1, 2, 3] on the peer and in a fresh reader. main gives the same result, so this is not a regression; limiting the sentence to groups whose transforms only set derived fields would keep it accurate.

  3. Test plan Before block and the run-count assertion (packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts:168). At this head the file fails 7 of 25 on main, not 6: the GrowingListNode cell also stops at expect(runs).toBe(5) (4 on main). AGENTS.md asks the Test plan to carry the real Before output, so it is worth refreshing that block. The run count also ties the test to how often the refresh runs the transform; the JSON comparisons after it already show the editor matching the document, so you could drop or loosen the count.


[fix] Keep list numbering right on an editor after several follow-up updates in a row that the binding reads again

A follow-up update (BlockIdExtension's naming, the update that runs a newly registered transform) may only set attributes or delete; anything else it changes is read again, and the read refreshes the derived fields of the lists it rebuilds. After the fourth such re-read in a row without a user's edit, the binding stopped refreshing, to cut off a loop: the editor then showed a list item's value differently from a fresh reader of its document. The last re-read now refreshes in its own update, with no refresh after it, so the loop is still cut off and the numbering is right.

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: CONTRIBUTING.md ("Changesets") and .changeset/README.md ("Behavior changes") ask a fix that changes observable behavior to add a Verify: paragraph. Here the list item value the author's editor shows changes, so something like:

Verify: list numbering (ListItemNode.getValue()) on the editor that made several BlockIdExtension naming updates in a row that the binding does not write, compared with a fresh read of the same document.

Comment on lines +277 to +278
* again at most this many times in a row (until a user's edit), and every
* re-read leaves the derived fields a fresh reader shows. Why: a transform

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 nit: "every re-read leaves the derived fields a fresh reader shows" does not hold for a group whose own transform changes structure. $refreshInLastRead reads that group again and then clears state.refresh, so it keeps numbering computed on the reverted structure. A number list whose $transform prepends an item on every run ends at [2, 3, 4] here against [1, 2, 3] on the peer and in a fresh reader. main behaves the same, so this is only about the comment: limiting it to groups whose transforms only set derived fields would keep it accurate.

// The first refresh, the one after each of the re-reads before the last
// (MAX_REREADS = 3), and the last re-read's own, whose change is read
// again in the same update.
expect(runs).toBe(5);

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 count is how often the transform runs, which ties the test to the refresh's internals (it is 4 on main, so this cell also fails there; the Test plan's Before block lists 6 failures, not 7). The JSON comparisons below already show the editor matching the document, so you could drop or loosen the count.

This branch was successfully deployed

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

Bug: [lexical-y] After refused naming follow-ups, the author shows a number list's wrapper item with value 1 where its own document reads 2

1 participant