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 <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-08-25 16:50:21 +01:00
committed by GitHub
parent a1219070fa
commit bb5b9357a7
3 changed files with 66 additions and 8 deletions

View File

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

View File

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

View File

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