From bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 25 Aug 2026 16:50:21 +0100 Subject: [PATCH] Fix TipTap editor mount race (#6779) ## Summary - wait for TipTap's mount lifecycle before reading the selection formatting tray's editor DOM - detach and reattach DOM listeners across editor view unmounts and remounts - cover mounted and pre-mount editor view access ## Testing - `just ci` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/composer-selection-formatting.spec.ts --project=smoke --grep "right-clicking selected composer text"` - Native Builderlab staging: reproduced the pre-mount crash on main, relaunched with the fix, and verified the composer mounts without the TipTap error Signed-off-by: kenny lopez --- .../messages/ui/SelectionFormattingTray.tsx | 36 ++++++++++++++----- .../selectionFormattingTrayEditorDom.test.mjs | 28 +++++++++++++++ .../ui/selectionFormattingTrayEditorDom.ts | 10 ++++++ 3 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs create mode 100644 desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index 84934d1826..ab092b5756 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -4,6 +4,7 @@ import type { Editor } from "@tiptap/react"; import { cn } from "@/shared/lib/cn"; import { FormattingToolbar } from "./FormattingToolbar"; +import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom"; type SelectionFormattingTrayProps = { editor: Editor | null; @@ -129,6 +130,7 @@ export function SelectionFormattingTray({ if ( suppressRightClickUpdatesRef.current || !editor || + !getMountedEditorDom(editor) || disabled || !editor.isEditable || !editor.isFocused @@ -161,7 +163,7 @@ export function SelectionFormattingTray({ return; } - const editorDom = editor.view.dom; + let editorDom: HTMLElement | null = null; const hide = () => setPosition(null); const handleContextMenu = () => { suppressRightClickUpdatesRef.current = true; @@ -176,26 +178,44 @@ export function SelectionFormattingTray({ if (event.button === 0) clearSuppression(); }; - scheduleUpdate(); + const detachEditorDom = () => { + if (!editorDom) return; + editorDom.removeEventListener("contextmenu", handleContextMenu); + editorDom.removeEventListener("pointerdown", handlePointerDown); + editorDom.removeEventListener("keydown", clearSuppression); + editorDom = null; + }; + + const attachEditorDom = () => { + const nextEditorDom = getMountedEditorDom(editor); + if (!nextEditorDom || nextEditorDom === editorDom) return; + detachEditorDom(); + editorDom = nextEditorDom; + editorDom.addEventListener("contextmenu", handleContextMenu); + editorDom.addEventListener("pointerdown", handlePointerDown); + editorDom.addEventListener("keydown", clearSuppression); + scheduleUpdate(); + }; + + editor.on("mount", attachEditorDom); + editor.on("unmount", detachEditorDom); 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); + attachEditorDom(); return () => { cancelScheduledUpdate(); + editor.off("mount", attachEditorDom); + editor.off("unmount", detachEditorDom); 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); + detachEditorDom(); window.removeEventListener("resize", scheduleUpdate); window.removeEventListener("scroll", scheduleUpdate, true); }; diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs new file mode 100644 index 0000000000..433af4664d --- /dev/null +++ b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom.ts"; + +test("returns null while the TipTap editor view is not mounted", () => { + const editor = { + get view() { + return new Proxy( + {}, + { + get(_target, key) { + throw new Error(`editor view cannot access ${String(key)}`); + }, + }, + ); + }, + }; + + assert.equal(getMountedEditorDom(editor), null); +}); + +test("returns the editor DOM after the TipTap view mounts", () => { + const dom = {}; + const editor = { view: { dom } }; + + assert.equal(getMountedEditorDom(editor), dom); +}); diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts new file mode 100644 index 0000000000..25733239ba --- /dev/null +++ b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts @@ -0,0 +1,10 @@ +import type { Editor } from "@tiptap/react"; + +/** Returns the editor DOM only after TipTap has mounted its view. */ +export function getMountedEditorDom(editor: Editor): HTMLElement | null { + try { + return editor.view.dom; + } catch { + return null; + } +}