mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user