Skip to content
Closed
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
154 changes: 154 additions & 0 deletions desktop/src/features/messages/ui/SelectionFormattingTray.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import { afterEach, before, test } from "node:test";

import { JSDOM } from "jsdom";

let nextAnimationFrameId = 0;
const animationFrames = new Map();

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});

before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
dom.window.requestAnimationFrame = (callback) => {
nextAnimationFrameId += 1;
animationFrames.set(nextAnimationFrameId, callback);
return nextAnimationFrameId;
};
dom.window.cancelAnimationFrame = (id) => animationFrames.delete(id);
});

afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
animationFrames.clear();
nextAnimationFrameId = 0;
});

function createEditorHarness() {
const listeners = new Map();
let editorDom;
let viewReads = 0;
const editor = {
isEditable: true,
isFocused: true,
isInitialized: false,
get view() {
viewReads += 1;
if (!editorDom) throw new Error("view is not mounted");
return { dom: editorDom };
},
emit(event) {
for (const handler of listeners.get(event) ?? []) handler();
},
create() {
this.emit("create");
this.isInitialized = true;
},
mount(domElement) {
editorDom = domElement;
this.emit("mount");
},
off(event, handler) {
listeners.get(event)?.delete(handler);
},
on(event, handler) {
const handlers = listeners.get(event) ?? new Set();
handlers.add(handler);
listeners.set(event, handlers);
},
unmount() {
editorDom = undefined;
this.isInitialized = false;
this.emit("unmount");
},
};

return {
editor,
listenerCount: (event) => listeners.get(event)?.size ?? 0,
viewReads: () => viewReads,
};
}

test("binds editor DOM listeners only while the view is mounted", async () => {
const harness = createEditorHarness();
const firstDom = document.createElement("div");
const secondDom = document.createElement("div");
const { createElement, StrictMode } = await import("react");
const { render } = await import("@testing-library/react");
const { SelectionFormattingTray } = await import(
"./SelectionFormattingTray.tsx"
);

// TipTap mounts EditorContent before this passive effect, then emits create and
// marks isInitialized on a timer. The create listener must cover that gap.
harness.editor.mount(firstDom);

const result = render(
createElement(
StrictMode,
null,
createElement(SelectionFormattingTray, { editor: harness.editor }),
),
);

assert.equal(harness.viewReads(), 0, "must not access view before create");
assert.equal(harness.listenerCount("create"), 1);
assert.equal(harness.listenerCount("mount"), 1);
assert.equal(harness.listenerCount("unmount"), 1);

harness.editor.create();
assert.equal(harness.viewReads(), 1);
firstDom.dispatchEvent(new dom.window.Event("contextmenu"));
firstDom.dispatchEvent(new dom.window.Event("keydown"));
assert.equal(animationFrames.size, 1);

harness.editor.unmount();
assert.equal(animationFrames.size, 0, "unmount must cancel queued updates");
assert.doesNotThrow(
() => harness.editor.create(),
"a delayed create from the unmounted view must be ignored",
);
assert.equal(
harness.viewReads(),
1,
"delayed create must not read the unmounted view",
);
assert.doesNotThrow(() =>
window.dispatchEvent(new dom.window.Event("resize")),
);
assert.equal(animationFrames.size, 1);
const [[frameId, frame]] = animationFrames;
animationFrames.delete(frameId);
assert.doesNotThrow(() => frame(), "global updates must be safe unmounted");
firstDom.dispatchEvent(new dom.window.Event("keydown"));
assert.equal(
animationFrames.size,
0,
"unmount must detach listeners from the old DOM",
);

harness.editor.mount(secondDom);
assert.equal(harness.viewReads(), 2);
secondDom.dispatchEvent(new dom.window.Event("contextmenu"));

result.unmount();
assert.equal(harness.listenerCount("create"), 0);
assert.equal(harness.listenerCount("mount"), 0);
assert.equal(harness.listenerCount("unmount"), 0);
animationFrames.clear();
secondDom.dispatchEvent(new dom.window.Event("keydown"));
assert.equal(
animationFrames.size,
0,
"React cleanup must detach listeners from the mounted view",
);
});
51 changes: 44 additions & 7 deletions desktop/src/features/messages/ui/SelectionFormattingTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export function SelectionFormattingTray({
}: SelectionFormattingTrayProps) {
const [position, setPosition] = React.useState<TrayPosition | null>(null);
const rafRef = React.useRef<number | null>(null);
const editorViewMountedRef = React.useRef(false);
const suppressRightClickUpdatesRef = React.useRef(false);
const trayRef = React.useRef<HTMLDivElement | null>(null);
const [trayWidth, setTrayWidth] = React.useState(0);
Expand All @@ -128,6 +129,7 @@ export function SelectionFormattingTray({
const updatePosition = React.useCallback(() => {
if (
suppressRightClickUpdatesRef.current ||
!editorViewMountedRef.current ||
!editor ||
disabled ||
!editor.isEditable ||
Expand Down Expand Up @@ -161,8 +163,9 @@ export function SelectionFormattingTray({
return;
}

const editorDom = editor.view.dom;
let createMayAttach = true;
const hide = () => setPosition(null);
let editorDom: HTMLElement | null = null;
const handleContextMenu = () => {
suppressRightClickUpdatesRef.current = true;
cancelScheduledUpdate();
Expand All @@ -175,27 +178,61 @@ export function SelectionFormattingTray({
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0) clearSuppression();
};
const detachEditorDomListeners = () => {
editorDom?.removeEventListener("contextmenu", handleContextMenu);
editorDom?.removeEventListener("pointerdown", handlePointerDown);
editorDom?.removeEventListener("keydown", clearSuppression);
editorDom = null;
};
const handleEditorUnmount = () => {
createMayAttach = false;
editorViewMountedRef.current = false;
detachEditorDomListeners();
cancelScheduledUpdate();
hide();
};
const attachEditorDomListeners = () => {
detachEditorDomListeners();
editorDom = editor.view.dom;
editorViewMountedRef.current = true;
editorDom.addEventListener("contextmenu", handleContextMenu);
editorDom.addEventListener("pointerdown", handlePointerDown);
editorDom.addEventListener("keydown", clearSuppression);
};
const handleEditorCreate = () => {
if (createMayAttach) attachEditorDomListeners();
};
const handleEditorMount = () => {
createMayAttach = true;
attachEditorDomListeners();
};

editor.on("create", handleEditorCreate);
editor.on("mount", handleEditorMount);
editor.on("unmount", handleEditorUnmount);
if (editor.isInitialized) {
attachEditorDomListeners();
}

scheduleUpdate();
editor.on("selectionUpdate", scheduleUpdate);
editor.on("transaction", scheduleUpdate);
editor.on("focus", scheduleUpdate);
editor.on("blur", hide);
editorDom.addEventListener("contextmenu", handleContextMenu);
editorDom.addEventListener("pointerdown", handlePointerDown);
editorDom.addEventListener("keydown", clearSuppression);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);

return () => {
editorViewMountedRef.current = false;
cancelScheduledUpdate();
editor.off("create", handleEditorCreate);
editor.off("mount", handleEditorMount);
editor.off("unmount", handleEditorUnmount);
editor.off("selectionUpdate", scheduleUpdate);
editor.off("transaction", scheduleUpdate);
editor.off("focus", scheduleUpdate);
editor.off("blur", hide);
editorDom.removeEventListener("contextmenu", handleContextMenu);
editorDom.removeEventListener("pointerdown", handlePointerDown);
editorDom.removeEventListener("keydown", clearSuppression);
detachEditorDomListeners();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
};
Expand Down
Loading