From ffd32c8b273ef959870217ed99cf9c2b23a06e1b Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 08:35:39 +0100 Subject: [PATCH] Paste another site's text without replaying it forever view.pasteText(text, event) re-asks every paste handler with the event it was given, so the composer's own handler replayed the paste until the stack overflowed (any rich paste from VS Code, Slack or a browser page). It now passes no event, and a ref keeps it from re-entering either way. The plain-text decision moves to shouldPastePlainText with its own tests. --- .../[bot_id]/(hooks)/use-composer-editor.ts | 22 ++++++++++++-- .../(utils)/should-paste-plain-text.test.ts | 29 +++++++++++++++++++ .../(utils)/should-paste-plain-text.ts | 19 ++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.test.ts create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.ts diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-editor.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-editor.ts index d150073..f4384de 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-editor.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-editor.ts @@ -12,6 +12,7 @@ import { } from "../(data)/composer-mention-kinds"; import ComposerMentionChip from "../(partials)/composer-mention-chip"; import { MentionMarkdown, mentionRef } from "../(utils)/mention-markdown"; +import shouldPastePlainText from "../(utils)/should-paste-plain-text"; import searchMentionItems, { type ComposerMentionItem } from "../(utils)/search-mention-items"; /** @@ -63,6 +64,7 @@ export default function useComposerEditor({ const [menu, setMenu] = useState(null); const menu_ref = useRef(null); + const replaying_paste = useRef(false); const showMenu = (next: ComposerMentionMenu | null) => { menu_ref.current = next; @@ -177,6 +179,9 @@ export default function useComposerEditor({ class: ComposerEditorClassName, }, handlePaste: (view, event) => { + /** `pasteText` re-runs this handler, so it must not send it round again. */ + if (replaying_paste.current) return false; + const files = [...(event.clipboardData?.files || [])]; if (files.length) { @@ -188,9 +193,22 @@ export default function useComposerEditor({ const text = event.clipboardData?.getData("text/plain") || ""; /** Our own copies keep their formatting; other sites' don't. */ - if (!html || html.includes("data-pm-slice") || !text) return false; + if (!shouldPastePlainText({ html, text })) return false; - return view.pasteText(text, event); + replaying_paste.current = true; + + try { + /** + * Without the event: `pasteText` re-asks every paste + * handler with the one it's given, and this one would + * replay the paste forever (a stack overflow), while a + * fresh event reads no clipboard data and falls through + * to the plain-text parse. + */ + return view.pasteText(text); + } finally { + replaying_paste.current = false; + } }, handleDrop: (_view, event) => { const files = [...(event.dataTransfer?.files || [])]; diff --git a/src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.test.ts b/src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.test.ts new file mode 100644 index 0000000..dadf10e --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import shouldPastePlainText from "./should-paste-plain-text"; + +describe("should paste plain text", () => { + test("replays another site's rich paste as plain text", () => { + expect( + shouldPastePlainText({ + html: 'GROK_BOT_MAILBOX_PASSWORD', + text: "GROK_BOT_MAILBOX_PASSWORD", + }), + ).toBe(true); + expect(shouldPastePlainText({ html: "

line one

line two

", text: "one\ntwo" })).toBe(true); + }); + + test("keeps our own copies formatted", () => { + expect( + shouldPastePlainText({ + html: '

bold

', + text: "**bold**", + }), + ).toBe(false); + }); + + test("leaves a plain paste and a file paste alone", () => { + expect(shouldPastePlainText({ html: "", text: "just text" })).toBe(false); + expect(shouldPastePlainText({ html: '', text: "" })).toBe(false); + expect(shouldPastePlainText({ html: "", text: "" })).toBe(false); + }); +}); diff --git a/src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.ts b/src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.ts new file mode 100644 index 0000000..64ccd62 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/should-paste-plain-text.ts @@ -0,0 +1,19 @@ +type Params = { + /** The clipboard's `text/html`. */ + html: string; + /** The clipboard's `text/plain`. */ + text: string; +}; + +/** + * Whether a paste is replayed as plain text instead of parsed as HTML: a + * rich paste from another site drops its styling (its markup and CSS), + * while our own copies (`data-pm-slice`) keep their formatting and a paste + * with no text (files, an image) is none of this handler's business. + */ +export default function shouldPastePlainText({ html, text }: Params) { + if (!text) return false; + if (!html) return false; + + return !html.includes("data-pm-slice"); +}