fix(composer): avoid unmounted TipTap view crashes

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-25 18:47:52 -07:00
committed by Carl
parent 52621c09be
commit 1179198074
8 changed files with 138 additions and 78 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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],
);

View File

@@ -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],
);

View File

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