Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/lexical-update-listener-errors.md
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.
31 changes: 0 additions & 31 deletions packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!")',
);
},
);
});
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([]);
});
},
);
46 changes: 34 additions & 12 deletions packages/lexical/src/LexicalUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();

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: when this cleanup throws, the listener(...payload) call below is skipped, so this listener misses the commit (it runs again on the next one). main does 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.

}
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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};

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, non-blocking: errors after the first are discarded here without any report. $beginUpdate already sends an error the caller cannot receive to onError (lines 1479-1486, covered by an error thrown while the editor is restored is reported too). Passing the second and later listener errors to editor._onError inside a try/catch would keep an independent listener bug visible while the caller still gets the first.

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, 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. $beginUpdate already reports its secondary commit error to onError inside a try/catch (lines 1481-1488). Doing the same for the second and later listener errors would still leave onError unused in the single-error case.

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: the second and later listener errors are discarded here without any report. Reporting them through onError inside a try/catch, as $beginUpdate does for a secondary commit error (lines 1481-1488), would keep the first-error contract and still leave a trace when a binding or history listener fails behind a failing app listener.

}
}
} 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

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, non-blocking: once this rethrows, the commit tail at lines 885-888 is skipped, so an editor.update() queued by a listener that ran after the failure stays queued until the next unrelated update. $beginUpdate schedules $triggerEnqueuedUpdates in a microtask for the same case (lines 1472-1475); doing that before the error leaves the commit would match it.

}

function hasSelectionChanged(
Expand Down
Loading