From a548c2f8115fece9b1120f077d8bc9d82afb68f4 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:03:29 +0800 Subject: [PATCH 1/9] [lexical] Bug Fix: Notify all update listeners before rethrowing ## Description A throwing update listener currently prevents later listeners from seeing an already committed state. Notify every update listener before rethrowing the first failure. Keep other listener types fail-fast, preserve the existing onError contract, and do not call an attempted update-listener cleanup twice after an exception. Closes #9514 ## Test plan ### Before Isolated production triggerListeners, extracted from the current source with only TypeScript annotations removed: ```text 8 dispatch cases: 3 passed, 5 failed Error: ["bad"] !== ["bad",1] Error: 2 !== 1 ``` ### After The same dispatch cases against the patched function: ```text 8 dispatch cases: 8 passed, 0 failed ``` Covers subsequent notifications, the original thrown value including undefined, cleanup errors, self-unregistering listeners, non-update fail-fast behavior, and _updating restoration. Full Vitest and code-quality checks are still pending in the fork; no full-suite pass is claimed. --- packages/lexical/src/LexicalUpdates.ts | 43 +++++++++++++++++++------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 053a62a9300..d79eb9d740c 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -938,6 +938,7 @@ export function triggerListeners( isCurrentlyEnqueuingUpdates: boolean, ...payload: MapListeners[T] ): void { + let rethrown: null | {error: unknown} = null; const previouslyUpdating = editor._updating; editor._updating = isCurrentlyEnqueuingUpdates; @@ -948,23 +949,41 @@ export function triggerListeners( >; const listeners = Array.from(listenerMap); for (const [listener, unregister] of listeners) { - if (unregister) { - unregister(); - } - // TypeScript's void-return rule lets a `=> void` callback return any value, - // so a listener like `() => arr.push(x)` hands back a number. Only a - // function is an unregister callback. - const result = listener(...payload); - const nextUnregister = typeof result === 'function' ? result : undefined; - if (listenerMap.has(listener)) { - listenerMap.set(listener, nextUnregister); - } else if (nextUnregister) { - nextUnregister(); + try { + if (unregister) { + if (type === 'update' && listenerMap.has(listener)) { + // A cleanup that throws, or whose next listener throws, has still + // been called. Do not call it again on the next commit. + listenerMap.set(listener, undefined); + } + unregister(); + } + // TypeScript's void-return rule lets a `=> void` callback return any value, + // so a listener like `() => arr.push(x)` hands back a number. Only a + // function is an unregister callback. + const result = listener(...payload); + const nextUnregister = typeof result === 'function' ? result : undefined; + if (listenerMap.has(listener)) { + listenerMap.set(listener, nextUnregister); + } else if (nextUnregister) { + nextUnregister(); + } + } catch (error) { + if (type !== 'update') { + throw error; + } + // Every update listener must see the committed state, even if an + // earlier listener throws. Keep the existing error contract: callers + // receive the first error and onError is not called for listeners. + rethrown ??= {error}; } } } finally { editor._updating = previouslyUpdating; } + if (rethrown !== null) { + throw rethrown.error; + } } function hasSelectionChanged( From 752732576b0208fc820384cd7117c163a9a76de7 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:05:54 +0800 Subject: [PATCH 2/9] [lexical] Chore: Cover update listener error isolation ## Description Cover later committed-state notifications, the first thrown value including undefined, cleanup failures and self-unregistration. Preserve onError behavior and non-update fail-fast dispatch. ## Test plan ### Before ```text Isolated current dispatcher: 8 cases, 3 passed, 5 failed ``` ### After ```text Isolated patched dispatcher: 8 cases, 8 passed, 0 failed ``` The full new Vitest tests and code-quality checks will be run on the completed fix branch. No full-suite pass is claimed by this commit. --- .../LexicalUpdateListenerIsolation.test.ts | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts diff --git a/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts b/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts new file mode 100644 index 00000000000..297eda8ffe4 --- /dev/null +++ b/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts @@ -0,0 +1,215 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +import { + $createParagraphNode, + $createTextNode, + $getRoot, + createEditor, + type LexicalEditor, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +function setup(rethrows = true) { + const errors: Error[] = []; + const editor = createEditor({ + onError: error => { + errors.push(error); + if (rethrows) { + throw error; + } + }, + }); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('before')), + ); + }, + {discrete: true}, + ); + return {editor, errors}; +} + +function commit(editor: LexicalEditor, text: string) { + editor.update( + () => { + ($getRoot().getFirstDescendant() as TextNode).setTextContent(text); + }, + {discrete: true}, + ); +} + +describe('update listener error isolation', () => { + test.each([true, false])( + 'later listeners see the commit and the next update, onError rethrows: %s', + rethrows => { + const {editor, errors} = setup(rethrows); + const failure = new Error('listener bug'); + let armed = true; + editor.registerUpdateListener(() => { + if (armed) { + armed = false; + throw failure; + } + }); + const commits: [string, string][] = []; + editor.registerUpdateListener(({editorState, prevEditorState}) => { + commits.push([ + prevEditorState.read(() => $getRoot().getTextContent()), + editorState.read(() => $getRoot().getTextContent()), + ]); + }); + + expect(() => commit(editor, 'after')).toThrow(failure); + expect(commits).toEqual([['before', 'after']]); + expect(editor._updating).toBe(false); + expect(editor._pendingEditorState).toBe(null); + expect(errors).toEqual([]); + + commit(editor, 'next'); + expect(commits).toEqual([ + ['before', 'after'], + ['after', 'next'], + ]); + }, + ); + + test('keeps the first of multiple listener errors', () => { + const {editor, errors} = setup(); + const first = new Error('first'); + const seen: string[] = []; + editor.registerUpdateListener(() => { + seen.push('first'); + throw first; + }); + editor.registerUpdateListener(() => { + seen.push('second'); + throw new Error('second'); + }); + editor.registerUpdateListener(() => { + seen.push('last'); + }); + + expect(() => commit(editor, 'after')).toThrow(first); + expect(seen).toEqual(['first', 'second', 'last']); + expect(errors).toEqual([]); + expect(editor._updating).toBe(false); + }); + + test.each([undefined, null, 0])( + 'a non-Error throw (%s) is preserved and does not stop notification', + failure => { + const {editor, errors} = setup(); + let notified = false; + editor.registerUpdateListener(() => { + throw failure; + }); + editor.registerUpdateListener(() => { + notified = true; + throw new Error('later error'); + }); + let thrown = false; + try { + commit(editor, 'after'); + } catch (error) { + thrown = true; + expect(error).toBe(failure); + } + expect(thrown).toBe(true); + expect(notified).toBe(true); + expect(errors).toEqual([]); + expect(editor._updating).toBe(false); + }, + ); + + test('does not repeat a throwing cleanup', () => { + const {editor} = setup(); + const failure = new Error('cleanup bug'); + let cleanupCalls = 0; + let notified = 0; + let armed = false; + editor.registerUpdateListener(() => { + return () => { + cleanupCalls++; + if (armed) { + throw failure; + } + }; + }); + editor.registerUpdateListener(() => { + notified++; + }); + + commit(editor, 'one'); + armed = true; + expect(() => commit(editor, 'two')).toThrow(failure); + expect(notified).toBe(2); + armed = false; + commit(editor, 'three'); + expect(cleanupCalls).toBe(1); + expect(notified).toBe(3); + }); + + test('does not repeat cleanup after a listener throws', () => { + const {editor} = setup(); + let cleanupCalls = 0; + let armed = false; + editor.registerUpdateListener(() => { + if (armed) { + armed = false; + throw new Error('listener bug'); + } + return () => { + cleanupCalls++; + }; + }); + + commit(editor, 'one'); + armed = true; + expect(() => commit(editor, 'two')).toThrow('listener bug'); + commit(editor, 'three'); + expect(cleanupCalls).toBe(1); + }); + + test('editable listeners still stop at the first error', () => { + const {editor, errors} = setup(); + const failure = new Error('editable listener bug'); + let notified = false; + editor.registerEditableListener(() => { + throw failure; + }); + editor.registerEditableListener(() => { + notified = true; + }); + + expect(() => editor.setEditable(!editor.isEditable())).toThrow(failure); + expect(notified).toBe(false); + expect(errors).toEqual([]); + expect(editor._updating).toBe(false); + }); + + test('cleans up a listener that unregisters itself', () => { + const {editor} = setup(); + let cleanups = 0; + let remove = () => {}; + remove = editor.registerUpdateListener(() => { + remove(); + return () => { + cleanups++; + }; + }); + + commit(editor, 'one'); + commit(editor, 'two'); + expect(cleanups).toBe(1); + }); +}); From 076c269a1aec0dad78ad27432639d3055fdbcbae Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:06:57 +0800 Subject: [PATCH 3/9] [lexical-y] Chore: Cover convergence after update listener errors ## Description Exercise a real two-client collaboration session with a throwing application listener registered before the binding. Check that the committed deletion and a subsequent edit reach every peer whether onError reports or rethrows. ## Test plan ### Before The control branch run 41127281136 failed both collaboration cases: the remote document retained t2z after the local deletion. ### After The same regression is now part of the fix branch. Full Vitest validation is pending on the completed change; no passing run is claimed by this commit. --- .../ThrowingUpdateListener.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts diff --git a/packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts new file mode 100644 index 00000000000..b76c5eccd29 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +import {$getRoot, type LexicalEditor} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import { + $block, + $p, + $selectIn, + expectConverged, + setup, +} from '../../utils/harness'; + +// #9514: a throwing application listener registered before the binding must +// not prevent the binding from writing an already committed delete. +describe.each(['reports', 'rethrows'] as const)( + 'a throwing update listener, onError %s', + mode => { + test('the binding writes the delete and every later edit', async () => { + let armed = false; + const reported: Error[] = []; + const failure = new Error('listener bug'); + const t = await setup( + () => + $getRoot().append( + $p('t1z t2z t3z t4z t5z t6z t7z t8z t9z'), + $p('tail line'), + ), + { + prepare: (editor: LexicalEditor) => { + editor._onError = error => { + reported.push(error); + if (mode === 'rethrows') { + throw error; + } + }; + editor.registerUpdateListener(() => { + if (armed) { + armed = false; + throw failure; + } + }); + }, + }, + ); + armed = true; + expect(() => + t.a.update(() => { + const at = $block([0]).getTextContent().indexOf('t2z'); + $selectIn([0], at, at + 3).removeText(); + }), + ).toThrow(failure); + t.a.update(() => void $selectIn([1], 9).insertText('!')); + await t.sync(); + await expectConverged( + t.peers, + 'paragraph("t1z t3z t4z t5z t6z t7z t8z t9z") / paragraph("tail line!")', + ); + expect(reported).toEqual([]); + }); + }, +); From c56764bcfab678f425033e30b643498a5b6e0f9d Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:08:12 +0800 Subject: [PATCH 4/9] [lexical-y] Chore: Promote the listener failure repro to regressions ## Description Move the #9514 expected-failure scenario to ThrowingUpdateListener.test.ts, where it runs as a normal regression for both onError contracts. Leave unrelated expected failures and the pending-state case intact. ## Test plan ### Before The original test.fails passes only because later collaboration listeners miss the deletion. Both new normal regression variants fail on the unpatched runtime in fork run 41127281136. ### After The expected-failure duplicate is removed in favor of the two normal regressions. The complete patched Vitest run is pending; no passing result is claimed by this commit. --- .../src/__tests__/unit/UpdateErrors.test.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts b/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts index 8c0645f4da4..9e028a5fb21 100644 --- a/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts +++ b/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts @@ -315,35 +315,4 @@ describe('the binding writes every committed change', () => { 'paragraph("t1z t2z t6z t9z") / paragraph("tail line")', ); }); - - // Open, #9514 (not caused by an update that throws): Lexical stops - // notifying a commit's update listeners when one of them throws, so a - // binding registered after the listener that threw never writes that - // commit. - test.fails( - "a commit whose update listeners stopped before the binding's is not written (#9514)", - async () => { - let armed = false; - const t = await setup($doc, { - prepare: (editor: LexicalEditor) => { - editor.registerUpdateListener(() => { - if (armed) { - armed = false; - throw new Error('listener bug'); - } - }); - }, - }); - armed = true; - expect(() => - t.a.update(() => void $selectWords('t2z').removeText()), - ).toThrow('listener bug'); - t.a.update(() => void $selectIn([1], 9).insertText('!')); - await t.sync(); - await expectConverged( - t.peers, - 'paragraph("t1z t3z t4z t5z t6z t7z t8z t9z") / paragraph("tail line!")', - ); - }, - ); }); From a950bd88b0f5f36d76d762eb5384f00b012e5212 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:08:57 +0800 Subject: [PATCH 5/9] [lexical] Chore: Document the update listener error fix ## Description Document the lexical patch behavior for update listener notification and cleanup after exceptions, including a manual Verify path. ## Test plan ### Before ```text Isolated current dispatcher: 8 cases, 3 passed, 5 failed ``` ### After ```text Isolated patched dispatcher: 8 cases, 8 passed, 0 failed ``` The final branch will be checked with the focused Vitest command and pnpm run ci-check, including check-changesets. Those full checks are pending. --- .changeset/lexical-update-listener-errors.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/lexical-update-listener-errors.md diff --git a/.changeset/lexical-update-listener-errors.md b/.changeset/lexical-update-listener-errors.md new file mode 100644 index 00000000000..38a254b09ab --- /dev/null +++ b/.changeset/lexical-update-listener-errors.md @@ -0,0 +1,9 @@ +--- +'lexical': patch +--- + +[fix] Notify every update listener before rethrowing the first listener error + +Update-listener cleanups that throw, or whose next listener throws, are not called again on the next commit. + +Verify: Register a throwing update listener before a history or collaboration listener. Later listeners must receive the same committed state, the first error must still reach the caller, and `onError` remains unused for listener errors. From e464c644b4b65740976bd5ecfab809054521754f Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:18:45 +0800 Subject: [PATCH 6/9] [lexical] Chore: Format update listener error handling ## Description Wrap the nextUnregister assignment after nesting the listener dispatch in error isolation. This changes formatting only. Closes #9514 ## Changeset .changeset/lexical-update-listener-errors.md documents the lexical patch. ## Test plan ### Before Control commit 6f466dc6e4b0b74ebebf96b4a1c6667942484fb7, fork run 41128429042: ```sh pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalUpdateErrors.test.ts packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts ``` ```text Test Files 2 failed | 2 passed (4) Tests 10 failed | 41 passed | 1 expected fail (52) ``` ### After The same command on fix commit a950bd88b0f5f36d76d762eb5384f00b012e5212, fork run 41129069653: ```text Test Files 4 passed (4) Tests 51 passed (51) ``` The duplicate #9514 expected-failure test was moved to normal regressions. pnpm run ci-check flagged only this source file for Prettier. All related lexical/lexical-y unit tests are running separately; code quality and the focused tests will be rerun on the formatted head. No full green CI result is claimed before those checks finish. --- packages/lexical/src/LexicalUpdates.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index d79eb9d740c..ec61432ccfa 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -962,7 +962,8 @@ export function triggerListeners( // so a listener like `() => arr.push(x)` hands back a number. Only a // function is an unregister callback. const result = listener(...payload); - const nextUnregister = typeof result === 'function' ? result : undefined; + const nextUnregister = + typeof result === 'function' ? result : undefined; if (listenerMap.has(listener)) { listenerMap.set(listener, nextUnregister); } else if (nextUnregister) { From 9ac38865365fe391e7a6c7c39177d8c7b1f31c37 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:45:07 +0800 Subject: [PATCH 7/9] [lexical] Bug Fix: Drain queued updates after listener failures ## Description When an update listener throws, later listeners now run and can queue editor.update calls. Rethrowing the first failure skips the commit tail that normally drains those updates, leaving them stalled until an unrelated external update. Schedule the existing queued-update pump in a microtask before rethrowing the first listener failure. Add two regressions for reporting and rethrowing onError handlers; both assert that the queued update commits once without another external update, the queue is emptied, and onError remains unused. Clarify the existing cleanup changeset wording. ## Changeset Update .changeset/lexical-update-listener-errors.md with the queued-update recovery behavior and a Verify path. ## Test plan ### Before Test-only control 9167f2c2327eaa8a5e0904c4aec6a0a4a077b9b9 preserves the previous runtime. Run: https://github.com/wozhenbang2004/lexical/actions/runs/41137920517 ```sh pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalUpdateErrors.test.ts packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts ``` ```text Test Files 1 failed | 3 passed (4) Tests 2 failed | 51 passed (53) AssertionError: expected 'after' to be 'queued' ``` Both new queue regressions fail; all 51 existing focused tests still pass. ### After The same command on 04df8f1e8fcc66cad6c3ca4f5117266302a0dccb, run https://github.com/wozhenbang2004/lexical/actions/runs/41139189328: ```text Test Files 4 passed (4) Tests 53 passed (53) ``` ```sh pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit packages/lexical-y/src/__tests__/unit ``` ```text Test Files 273 passed | 2 skipped (275) Tests 12920 passed | 203 expected fail | 2 skipped (13125) ``` pnpm run ci-check succeeded, including TypeScript, Flow, ESLint, Prettier and changesets. Formatting diagnostics produced no diff. ```text Changeset check passed: 51 pending changeset(s) valid. Found 0 errors ``` Ubuntu 24.04.5 and Node 24.22.1 were exercised. Node 22, browser-mode and E2E suites were not exercised by this validation run. --- .changeset/lexical-update-listener-errors.md | 4 +- packages/lexical/src/LexicalUpdates.ts | 2 + .../LexicalUpdateListenerIsolation.test.ts | 54 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.changeset/lexical-update-listener-errors.md b/.changeset/lexical-update-listener-errors.md index 38a254b09ab..f16ad59588c 100644 --- a/.changeset/lexical-update-listener-errors.md +++ b/.changeset/lexical-update-listener-errors.md @@ -4,6 +4,6 @@ [fix] Notify every update listener before rethrowing the first listener error -Update-listener cleanups that throw, or whose next listener throws, are not called again on the next commit. +Updates queued by update listeners run after a listener error without waiting for another external update. An update listener's previous cleanup is not repeated when the cleanup throws, the listener throws on its next call, or the listener unregisters itself. -Verify: Register a throwing update listener before a history or collaboration listener. Later listeners must receive the same committed state, the first error must still reach the caller, and `onError` remains unused for listener errors. +Verify: Register a throwing update listener before a history or collaboration listener. Later listeners must receive the same committed state, the first error must still reach the caller, and `onError` remains unused for listener errors. Queue an update from a later listener and verify it commits without another external update. diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index ec61432ccfa..661dd3a9c1a 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -983,6 +983,8 @@ export function triggerListeners( editor._updating = previouslyUpdating; } if (rethrown !== null) { + // A listener may have queued updates before the commit was interrupted. + scheduleMicroTask(() => $triggerEnqueuedUpdates(editor)); throw rethrown.error; } } diff --git a/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts b/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts index 297eda8ffe4..3200ed12899 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts @@ -212,4 +212,58 @@ describe('update listener error isolation', () => { commit(editor, 'two'); expect(cleanups).toBe(1); }); + + test.each([true, false])( + 'queued updates run after a listener error, onError rethrows: %s', + async rethrows => { + const {editor, errors} = setup(rethrows); + const failure = new Error('listener bug'); + editor.registerUpdateListener(({editorState}) => { + if (editorState.read(() => $getRoot().getTextContent()) === 'after') { + throw failure; + } + }); + let queued = false; + let queuedCalls = 0; + editor.registerUpdateListener(({editorState}) => { + if ( + !queued && + editorState.read(() => $getRoot().getTextContent()) === 'after' + ) { + queued = true; + editor.update( + () => { + queuedCalls++; + ($getRoot().getFirstDescendant() as TextNode).setTextContent( + 'queued', + ); + }, + {discrete: true}, + ); + } + }); + let thrown: unknown; + try { + commit(editor, 'after'); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(failure); + expect(queuedCalls).toBe(0); + expect(editor._updating).toBe(false); + + await Promise.resolve(); + const text = editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); + expect(text).toBe('queued'); + expect(queuedCalls).toBe(1); + expect(editor._updates).toHaveLength(0); + expect(editor._pendingEditorState).toBe(null); + expect(editor._updating).toBe(false); + expect(errors).toEqual([]); + await Promise.resolve(); + expect(queuedCalls).toBe(1); + }, + ); }); From 6d65420b71eeeacf5b369a11c2204fed46b65a31 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:52:15 +0800 Subject: [PATCH 8/9] [lexical] Chore: Cover cleanup when a listener unregisters on a later call ## Description The self-unregistering update-listener test previously removed the listener on its first call, before any cleanup was stored. Parameterize it for both the first and second call. The second-call case must run the previous and newly returned cleanup once each, and a third commit must cause no further listener calls or cleanups. ## Test plan ### Before Control 19acd9d34ccc4c965d9db180ed869b53fd7593d1 uses the exact candidate tests with only the early cleanup-clearing block removed. Run: https://github.com/wozhenbang2004/lexical/actions/runs/41146246337 ```sh pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalUpdateErrors.test.ts packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts packages/lexical-y/src/__tests__/unit/regressions/ThrowingUpdateListener.test.ts ``` ```text Test Files 1 failed | 3 passed (4) Tests 3 failed | 51 passed (54) AssertionError: expected 3 to be 2 // Object.is equality ``` The new second-call case fails because the stored cleanup is invoked twice. The first-call case passes; the other two failures are the existing cleanup-after-exception regressions. ### After The same command on candidate 99897dda119faf86115004d73426ee58ea2936ff, run https://github.com/wozhenbang2004/lexical/actions/runs/41146060897: ```text Test Files 4 passed (4) Tests 54 passed (54) ``` ```sh pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit packages/lexical-y/src/__tests__/unit ``` ```text Test Files 273 passed | 2 skipped (275) Tests 12921 passed | 203 expected fail | 2 skipped (13126) ``` pnpm run ci-check succeeded, including TypeScript, Flow, ESLint, Prettier and changesets. Formatting diagnostics produced no diff. ```text Changeset check passed: 51 pending changeset(s) valid. Found 0 errors ``` Ubuntu 24.04.5 and Node 24.22.1 were exercised. Node 22, browser-mode and E2E suites were not exercised by these validation runs. --- .../LexicalUpdateListenerIsolation.test.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts b/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts index 3200ed12899..3ad9b8d0ab2 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUpdateListenerIsolation.test.ts @@ -197,21 +197,34 @@ describe('update listener error isolation', () => { expect(editor._updating).toBe(false); }); - test('cleans up a listener that unregisters itself', () => { - const {editor} = setup(); - let cleanups = 0; - let remove = () => {}; - remove = editor.registerUpdateListener(() => { - remove(); - return () => { - cleanups++; - }; - }); + test.each([1, 2])( + 'cleans up a listener that unregisters itself on call %s', + unregisterOn => { + const {editor} = setup(); + let cleanups = 0; + let calls = 0; + let remove = () => {}; + remove = editor.registerUpdateListener(() => { + calls++; + if (calls === unregisterOn) { + remove(); + } + return () => { + cleanups++; + }; + }); - commit(editor, 'one'); - commit(editor, 'two'); - expect(cleanups).toBe(1); - }); + commit(editor, 'one'); + expect(calls).toBe(1); + expect(cleanups).toBe(unregisterOn === 1 ? 1 : 0); + commit(editor, 'two'); + expect(calls).toBe(unregisterOn); + expect(cleanups).toBe(unregisterOn); + commit(editor, 'three'); + expect(calls).toBe(unregisterOn); + expect(cleanups).toBe(unregisterOn); + }, + ); test.each([true, false])( 'queued updates run after a listener error, onError rethrows: %s', From a785a465a8e89e4c498ac87fe31aa821207addd4 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:37:31 -0700 Subject: [PATCH 9/9] [lexical] Documentation Update: Clarify cleanup-error notification behavior ## Description Clarify the existing changeset without changing runtime code. A listener whose own previous cleanup throws remains skipped for that commit; notification continues to the other listeners. Replace the overbroad summary and document this exception. ## Test plan Manual comparison of the changeset with the existing cleanup/listener control flow and inspection of the two changed documentation lines. ### Before The summary said: `[fix] Notify every update listener before rethrowing the first listener error`. It did not state the own-cleanup exception. ### After The summary says: `[fix] Continue notifying update listeners after listener or cleanup errors`. The next paragraph now begins: `As before, a listener whose own cleanup throws is skipped for that commit.` Only this changeset file is edited. Automated tests, check-changesets, and the browser/platform matrix were not rerun for this wording-only change. --- .changeset/lexical-update-listener-errors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/lexical-update-listener-errors.md b/.changeset/lexical-update-listener-errors.md index f16ad59588c..0a6cee29486 100644 --- a/.changeset/lexical-update-listener-errors.md +++ b/.changeset/lexical-update-listener-errors.md @@ -2,8 +2,8 @@ 'lexical': patch --- -[fix] Notify every update listener before rethrowing the first listener error +[fix] Continue notifying update listeners after listener or cleanup errors -Updates queued by update listeners run after a listener error without waiting for another external update. An update listener's previous cleanup is not repeated when the cleanup throws, the listener throws on its next call, or the listener unregisters itself. +As before, a listener whose own cleanup throws is skipped for that commit. Updates queued by update listeners run after a listener error without waiting for another external update. An update listener's previous cleanup is not repeated when the cleanup throws, the listener throws on its next call, or the listener unregisters itself. Verify: Register a throwing update listener before a history or collaboration listener. Later listeners must receive the same committed state, the first error must still reach the caller, and `onError` remains unused for listener errors. Queue an update from a later listener and verify it commits without another external update.