From 3d103ad168c027fd50df8553bb99dbad6791314c Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Tue, 25 Aug 2026 12:42:51 -0400 Subject: [PATCH] fix(desktop): guard unmounted formatting tray editor Co-authored-by: Arjun Mahanti Signed-off-by: Arjun Mahanti --- .../ui/SelectionFormattingTray.test.mjs | 56 +++++++++++++++++++ .../messages/ui/SelectionFormattingTray.tsx | 34 +++++++---- 2 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/messages/ui/SelectionFormattingTray.test.mjs diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.test.mjs b/desktop/src/features/messages/ui/SelectionFormattingTray.test.mjs new file mode 100644 index 0000000000..6d34291f74 --- /dev/null +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("does not read the TipTap view while the editor is unmounted", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { SelectionFormattingTray } = await import( + "./SelectionFormattingTray.tsx" + ); + let viewReads = 0; + const unmountedEditor = { + get isDestroyed() { + return true; + }, + get view() { + viewReads += 1; + throw new Error("TipTap view is not mounted"); + }, + }; + + render( + React.createElement( + React.StrictMode, + null, + React.createElement(SelectionFormattingTray, { + editor: unmountedEditor, + }), + ), + ); + + assert.equal(viewReads, 0); +}); diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index 84934d1826..6a82fb1341 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -26,6 +26,8 @@ function clamp(value: number, min: number, max: number): number { } function getSelectionRect(editor: Editor): DOMRect | null { + if (editor.isDestroyed) return null; + const { from, to } = editor.state.selection; try { @@ -46,15 +48,19 @@ function getSelectionRect(editor: Editor): DOMRect | null { // Fall back to the caret coordinates below. } - const startCoords = editor.view.coordsAtPos(from); - const endCoords = editor.view.coordsAtPos(to); - const left = Math.min(startCoords.left, endCoords.left); - const right = Math.max(startCoords.right, endCoords.right); - const top = Math.min(startCoords.top, endCoords.top); - const bottom = Math.max(startCoords.bottom, endCoords.bottom); + try { + const startCoords = editor.view.coordsAtPos(from); + const endCoords = editor.view.coordsAtPos(to); + const left = Math.min(startCoords.left, endCoords.left); + const right = Math.max(startCoords.right, endCoords.right); + const top = Math.min(startCoords.top, endCoords.top); + const bottom = Math.max(startCoords.bottom, endCoords.bottom); - if (right <= left && bottom <= top) return null; - return new DOMRect(left, top, Math.max(1, right - left), bottom - top); + if (right <= left && bottom <= top) return null; + return new DOMRect(left, top, Math.max(1, right - left), bottom - top); + } catch { + return null; + } } function getTrayPosition( @@ -129,6 +135,7 @@ export function SelectionFormattingTray({ if ( suppressRightClickUpdatesRef.current || !editor || + editor.isDestroyed || disabled || !editor.isEditable || !editor.isFocused @@ -155,13 +162,20 @@ export function SelectionFormattingTray({ React.useEffect(() => { suppressRightClickUpdatesRef.current = false; - if (!editor) { + if (!editor || editor.isDestroyed) { cancelScheduledUpdate(); setPosition(null); return; } - const editorDom = editor.view.dom; + let editorDom: HTMLElement; + try { + editorDom = editor.view.dom; + } catch { + cancelScheduledUpdate(); + setPosition(null); + return; + } const hide = () => setPosition(null); const handleContextMenu = () => { suppressRightClickUpdatesRef.current = true;