fix(desktop): guard unmounted formatting tray editor

Co-authored-by: Arjun Mahanti <arjun@squareup.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-08-25 12:42:51 -04:00
parent b191f35f1d
commit 3d103ad168
2 changed files with 80 additions and 10 deletions

View File

@@ -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("<!doctype html><html><body></body></html>", {
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);
});

View File

@@ -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;