From 1179198074c435658f2f0b43baa3c7add00100df Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 18:47:52 -0700 Subject: [PATCH] fix(composer): avoid unmounted TipTap view crashes Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../lib/composerEditorGeometry.test.mjs | 30 +++++++ .../messages/lib/composerEditorGeometry.ts | 89 +++++++++++++++++++ .../composerEditorView.test.mjs} | 14 +-- .../composerEditorView.ts} | 0 .../lib/useComposerSpoilerParticles.ts | 7 +- .../features/messages/lib/useLinkEditor.tsx | 31 +------ .../messages/lib/useRichTextEditor.ts | 8 +- .../messages/ui/SelectionFormattingTray.tsx | 37 +------- 8 files changed, 138 insertions(+), 78 deletions(-) create mode 100644 desktop/src/features/messages/lib/composerEditorGeometry.test.mjs create mode 100644 desktop/src/features/messages/lib/composerEditorGeometry.ts rename desktop/src/features/messages/{ui/selectionFormattingTrayEditorDom.test.mjs => lib/composerEditorView.test.mjs} (65%) rename desktop/src/features/messages/{ui/selectionFormattingTrayEditorDom.ts => lib/composerEditorView.ts} (100%) diff --git a/desktop/src/features/messages/lib/composerEditorGeometry.test.mjs b/desktop/src/features/messages/lib/composerEditorGeometry.test.mjs new file mode 100644 index 0000000000..613889f81d --- /dev/null +++ b/desktop/src/features/messages/lib/composerEditorGeometry.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getEditorLinkRect, + getEditorSelectionRect, +} from "./composerEditorGeometry.ts"; + +function unmountedEditor() { + return { + get view() { + return new Proxy( + {}, + { + get(_target, key) { + throw new Error(`editor view cannot access ${String(key)}`); + }, + }, + ); + }, + }; +} + +test("selection geometry returns null instead of throwing after view teardown", () => { + assert.equal(getEditorSelectionRect(unmountedEditor(), 1, 2), null); +}); + +test("link geometry returns null instead of throwing after view teardown", () => { + assert.equal(getEditorLinkRect(unmountedEditor(), 1, 2), null); +}); diff --git a/desktop/src/features/messages/lib/composerEditorGeometry.ts b/desktop/src/features/messages/lib/composerEditorGeometry.ts new file mode 100644 index 0000000000..0dd15461f5 --- /dev/null +++ b/desktop/src/features/messages/lib/composerEditorGeometry.ts @@ -0,0 +1,89 @@ +import type { Editor } from "@tiptap/react"; + +export type EditorRect = { + left: number; + top: number; + width: number; + height: number; + right: number; + bottom: number; +}; + +export function getEditorSelectionRect( + editor: Editor, + from: number, + to: number, +): EditorRect | null { + try { + const range = document.createRange(); + const start = editor.view.domAtPos(from); + const end = editor.view.domAtPos(to); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + + const clientRects = Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0 || rect.height > 0, + ); + const rect = clientRects[0] ?? range.getBoundingClientRect(); + range.detach(); + if (rect.width > 0 || rect.height > 0) return rect; + + 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 { + left, + top, + width: Math.max(1, right - left), + height: bottom - top, + right, + bottom, + }; + } catch { + return null; + } +} + +export function getEditorLinkRect( + editor: Editor, + from: number, + to: number, +): EditorRect | null { + try { + const range = document.createRange(); + const start = editor.view.domAtPos(from); + const end = editor.view.domAtPos(to); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + + const rect = range.getBoundingClientRect(); + range.detach(); + if (rect.width > 0 || rect.height > 0) { + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.right, + bottom: rect.bottom, + }; + } + + const coords = editor.view.coordsAtPos(from); + return { + left: coords.left, + top: coords.top, + width: Math.max(1, coords.right - coords.left), + height: Math.max(1, coords.bottom - coords.top), + right: coords.right, + bottom: coords.bottom, + }; + } catch { + return null; + } +} diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs b/desktop/src/features/messages/lib/composerEditorView.test.mjs similarity index 65% rename from desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs rename to desktop/src/features/messages/lib/composerEditorView.test.mjs index 433af4664d..05643a28e3 100644 --- a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs +++ b/desktop/src/features/messages/lib/composerEditorView.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom.ts"; +import { getMountedEditorDom } from "./composerEditorView.ts"; -test("returns null while the TipTap editor view is not mounted", () => { - const editor = { +function unmountedEditor() { + return { get view() { return new Proxy( {}, @@ -16,13 +16,13 @@ test("returns null while the TipTap editor view is not mounted", () => { ); }, }; +} - assert.equal(getMountedEditorDom(editor), null); +test("returns null while the TipTap editor view is not mounted", () => { + assert.equal(getMountedEditorDom(unmountedEditor()), null); }); test("returns the editor DOM after the TipTap view mounts", () => { const dom = {}; - const editor = { view: { dom } }; - - assert.equal(getMountedEditorDom(editor), dom); + assert.equal(getMountedEditorDom({ view: { dom } }), dom); }); diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts b/desktop/src/features/messages/lib/composerEditorView.ts similarity index 100% rename from desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts rename to desktop/src/features/messages/lib/composerEditorView.ts diff --git a/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts b/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts index 53ef0a5801..7e0092f17b 100644 --- a/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts +++ b/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts @@ -3,6 +3,7 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { mountSpoilerParticleCanvas } from "@/shared/ui/SpoilerParticles"; +import { getMountedEditorDom } from "./composerEditorView"; type ComposerSpoilerMount = { canvas: HTMLCanvasElement; @@ -23,10 +24,8 @@ export function useComposerSpoilerParticles( let teardown: (() => void) | undefined; const setup = () => { - let editorRoot: HTMLElement; - try { - editorRoot = editor.view.dom as HTMLElement; - } catch { + const editorRoot = getMountedEditorDom(editor); + if (!editorRoot) { if (!cancelled) setupFrame = window.requestAnimationFrame(setup); return; } diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 22098ae76c..4e69815ac1 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -22,6 +22,7 @@ import { type LinkEditorInitialFocus, } from "./linkEditorFocus"; import { openPopoverLink } from "./openPopoverLink"; +import { getEditorLinkRect } from "./composerEditorGeometry"; type DraftState = { text: string; @@ -80,35 +81,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { (info: LinkSelectionInfo): LinkCardState["rect"] | null => { const editor = richText.editor; if (!editor) return null; - - try { - const range = document.createRange(); - const start = editor.view.domAtPos(info.from); - const end = editor.view.domAtPos(info.to); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset); - - const rect = range.getBoundingClientRect(); - range.detach(); - if (rect.width > 0 || rect.height > 0) { - return { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - }; - } - } catch { - // Fall through to the caret coordinates below. - } - - const coords = editor.view.coordsAtPos(info.from); - return { - left: coords.left, - top: coords.top, - width: Math.max(1, coords.right - coords.left), - height: Math.max(1, coords.bottom - coords.top), - }; + return getEditorLinkRect(editor, info.from, info.to); }, [richText.editor], ); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 87574e7972..789a1900c0 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -756,7 +756,7 @@ export function useRichTextEditor({ .setMeta("preventUpdate", true); tr.setSelection(TextSelection.atEnd(tr.doc)); editor.view.dispatch(tr); - editor.view.focus(); + editor.commands.focus(); }, [editor], ); @@ -852,7 +852,7 @@ export function useRichTextEditor({ const cursorPM = afterNode + (text ? text.length : 0); tr = tr.setSelection(TextSelection.create(tr.doc, cursorPM)); editor.view.dispatch(tr); - editor.view.focus(); + editor.commands.focus(); return; } // No node type (shouldn't happen) → fall through to literal text. @@ -874,7 +874,7 @@ export function useRichTextEditor({ } settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection); editor.view.dispatch(tr); - editor.view.focus(); + editor.commands.focus(); if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], @@ -933,7 +933,7 @@ export function useRichTextEditor({ const cursorPM = tr.mapping.map(end); tr.setSelection(TextSelection.create(tr.doc, cursorPM)); editor.view.dispatch(tr); - editor.view.focus(); + editor.commands.focus(); }, [editor], ); diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index ab092b5756..0161327aa5 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -4,7 +4,8 @@ import type { Editor } from "@tiptap/react"; import { cn } from "@/shared/lib/cn"; import { FormattingToolbar } from "./FormattingToolbar"; -import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom"; +import { getEditorSelectionRect } from "../lib/composerEditorGeometry"; +import { getMountedEditorDom } from "../lib/composerEditorView"; type SelectionFormattingTrayProps = { editor: Editor | null; @@ -26,38 +27,6 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -function getSelectionRect(editor: Editor): DOMRect | null { - const { from, to } = editor.state.selection; - - try { - const range = document.createRange(); - const start = editor.view.domAtPos(from); - const end = editor.view.domAtPos(to); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset); - - const clientRects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0 || rect.height > 0, - ); - const rect = clientRects[0] ?? range.getBoundingClientRect(); - range.detach(); - - if (rect.width > 0 || rect.height > 0) return rect; - } catch { - // 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); - - if (right <= left && bottom <= top) return null; - return new DOMRect(left, top, Math.max(1, right - left), bottom - top); -} - function getTrayPosition( editor: Editor, trayWidth: number, @@ -73,7 +42,7 @@ function getTrayPosition( ); if (selectedText.trim().length === 0) return null; - const rect = getSelectionRect(editor); + const rect = getEditorSelectionRect(editor, selection.from, selection.to); if (!rect) return null; const viewportWidth = window.innerWidth;