Repository navigation
[lexical][lexical-y] Bug Fix: Continue notifying update listeners after failures #9614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a548c2f
7527325
076c269
c56764b
a950bd8
e464c64
9ac3886
6d65420
a785a46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| 'lexical': patch | ||
| --- | ||
|
|
||
| [fix] Continue notifying update listeners after listener or cleanup errors | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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([]); | ||
| }); | ||
| }, | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -938,6 +938,7 @@ export function triggerListeners<T extends keyof MapListeners>( | |
| isCurrentlyEnqueuingUpdates: boolean, | ||
| ...payload: MapListeners[T] | ||
| ): void { | ||
| let rethrown: null | {error: unknown} = null; | ||
| const previouslyUpdating = editor._updating; | ||
| editor._updating = isCurrentlyEnqueuingUpdates; | ||
|
|
||
|
|
@@ -948,23 +949,44 @@ export function triggerListeners<T extends keyof MapListeners>( | |
| >; | ||
| 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; | ||
| } | ||
|
Comment on lines
+973
to
+975
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Possible follow-up: decorator and text-content listeners (and mutation listeners, which never reach this function) run before the update listeners in the same commit (lines 839-867) and still stop at their first throw, so a throwing text-content listener still keeps a collaboration binding from writing the commit. Since the description scopes this change to update listeners, a follow-up issue would track it. |
||
| // 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}; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking: errors after the first are discarded here without any report. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking: every error after the first is discarded here without any report. Before this change those listeners never ran, so nothing regresses, but a failing binding or history listener behind a failing app listener now leaves no trace. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: the second and later listener errors are discarded here without any report. Reporting them through |
||
| } | ||
| } | ||
| } finally { | ||
| editor._updating = previouslyUpdating; | ||
| } | ||
| if (rethrown !== null) { | ||
| // A listener may have queued updates before the commit was interrupted. | ||
| scheduleMicroTask(() => $triggerEnqueuedUpdates(editor)); | ||
| throw rethrown.error; | ||
| } | ||
|
Comment on lines
+985
to
+989
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking: once this rethrows, the commit tail at lines 885-888 is skipped, so an |
||
| } | ||
|
|
||
| function hasSelectionChanged( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: when this cleanup throws, the
listener(...payload)call below is skipped, so this listener misses the commit (it runs again on the next one).maindoes the same, but the changeset says every update listener is notified. Either call the listener after a failed cleanup, or mention the exception in the changeset.