From 70df0a3d95b4c51adb4cf32f861962bf21938288 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 18:24:40 +0000 Subject: [PATCH] fix: create TipTap suggestion configs directly in useMemo to ensure stable function references The previous approach using createSuggestionConfig() helper function was still broken because it created NEW function references for items() and render() on every call, even though the result was memoized. This commit completely rewrites the suggestion config creation to match the main branch pattern: 1. Create each TipTap config directly inside its own useMemo 2. Define items() and render() functions inline within that useMemo 3. Only recreate when the source config (mentionConfig/emojiConfig/slashConfig) changes This ensures: - items() function has stable reference (created once per config) - render() function has stable reference (created once per config) - TipTap receives same function references across renders - Suggestion state remains intact Removed unused createSuggestionConfig() helper and SuggestionOptions import. This matches exactly how the working main branch creates suggestion configs. --- src/components/editor/NostrEditor.tsx | 404 ++++++++++++++++++-------- 1 file changed, 283 insertions(+), 121 deletions(-) diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index a658087..e9fcdff 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -16,7 +16,6 @@ import { import StarterKit from "@tiptap/starter-kit"; import Mention from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; -import type { SuggestionOptions } from "@tiptap/suggestion"; import tippy from "tippy.js"; import type { Instance as TippyInstance } from "tippy.js"; import "tippy.js/dist/tippy.css"; @@ -311,103 +310,6 @@ function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) { }); } -/** - * Create a TipTap suggestion configuration from our SuggestionConfig - * - * This creates a proper TipTap suggestion config that handles async search - * correctly by using the built-in items() function. - */ -function createSuggestionConfig( - config: SuggestionConfig, - handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, -): Omit, "editor"> { - return { - char: config.char, - allowSpaces: config.allowSpaces ?? false, - allow: config.allow, - // Use async items() for search - TipTap handles this correctly - items: async ({ query }) => { - return await config.search(query); - }, - render: () => { - let component: ReactRenderer | null = null; - let popup: TippyInstance[] | null = null; - let editorRef: unknown = null; - - return { - onStart: (props) => { - editorRef = props.editor; - - component = new ReactRenderer(config.component as never, { - props: { - items: props.items, - command: props.command, - onClose: () => popup?.[0]?.hide(), - }, - editor: props.editor, - }); - - if (!props.clientRect) return; - - popup = tippy("body", { - getReferenceClientRect: props.clientRect as () => DOMRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: config.placement ?? "bottom-start", - zIndex: 100, - }); - }, - - onUpdate(props) { - // Update component with new items and command - if (component) { - component.updateProps({ - items: props.items, - command: props.command, - }); - } - - // Update popup position - if (props.clientRect && popup?.[0]) { - popup[0].setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); - } - }, - - onKeyDown(props) { - if (props.event.key === "Escape") { - popup?.[0]?.hide(); - return true; - } - - // Ctrl/Cmd+Enter always submits - if ( - props.event.key === "Enter" && - (props.event.ctrlKey || props.event.metaKey) - ) { - popup?.[0]?.hide(); - handleSubmitRef.current(editorRef); - return true; - } - - return component?.ref?.onKeyDown(props.event) ?? false; - }, - - onExit() { - popup?.[0]?.destroy(); - component?.destroy(); - component = null; - popup = null; - }, - }; - }, - }; -} - export const NostrEditor = forwardRef( ( { @@ -557,29 +459,289 @@ export const NostrEditor = forwardRef( [suggestions], ); - // Memoize TipTap suggestion configs separately to ensure stable references - // This is critical - TipTap compares these by reference and reinitializes if they change - const tipTapMentionConfig = useMemo( - () => - mentionConfig - ? createSuggestionConfig(mentionConfig, handleSubmitRef) - : null, - [mentionConfig], - ); - const tipTapEmojiConfig = useMemo( - () => - emojiConfig - ? createSuggestionConfig(emojiConfig, handleSubmitRef) - : null, - [emojiConfig], - ); - const tipTapSlashConfig = useMemo( - () => - slashConfig - ? createSuggestionConfig(slashConfig, handleSubmitRef) - : null, - [slashConfig], - ); + // Create TipTap suggestion configs directly inline (matching main branch pattern) + // This ensures stable function references for items() and render() + const tipTapMentionConfig = useMemo(() => { + if (!mentionConfig) return null; + + return { + char: mentionConfig.char, + allowSpaces: mentionConfig.allowSpaces ?? false, + allow: mentionConfig.allow, + items: async ({ query }: { query: string }) => { + return await mentionConfig.search(query); + }, + render: () => { + let component: ReactRenderer | null = null; + let popup: TippyInstance[] | null = null; + let editorRef: unknown = null; + + return { + onStart: (props: { + editor: unknown; + items: unknown[]; + command: unknown; + clientRect?: () => DOMRect; + }) => { + editorRef = props.editor; + component = new ReactRenderer(mentionConfig.component as never, { + props: { + items: props.items, + command: props.command, + onClose: () => popup?.[0]?.hide(), + }, + editor: props.editor, + }); + + if (!props.clientRect) return; + + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: mentionConfig.placement ?? "bottom-start", + zIndex: 100, + }); + }, + + onUpdate(props: { + items: unknown[]; + command: unknown; + clientRect?: () => DOMRect; + }) { + if (component) { + component.updateProps({ + items: props.items, + command: props.command, + }); + } + + if (props.clientRect && popup?.[0]) { + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + } + }, + + onKeyDown(props: { event: KeyboardEvent }) { + if (props.event.key === "Escape") { + popup?.[0]?.hide(); + return true; + } + + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup?.[0]?.hide(); + handleSubmitRef.current(editorRef); + return true; + } + + return component?.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup?.[0]?.destroy(); + component?.destroy(); + component = null; + popup = null; + }, + }; + }, + }; + }, [mentionConfig]); + + const tipTapEmojiConfig = useMemo(() => { + if (!emojiConfig) return null; + + return { + char: emojiConfig.char, + allowSpaces: emojiConfig.allowSpaces ?? false, + allow: emojiConfig.allow, + items: async ({ query }: { query: string }) => { + return await emojiConfig.search(query); + }, + render: () => { + let component: ReactRenderer | null = null; + let popup: TippyInstance[] | null = null; + let editorRef: unknown = null; + + return { + onStart: (props: { + editor: unknown; + items: unknown[]; + command: unknown; + clientRect?: () => DOMRect; + }) => { + editorRef = props.editor; + component = new ReactRenderer(emojiConfig.component as never, { + props: { + items: props.items, + command: props.command, + onClose: () => popup?.[0]?.hide(), + }, + editor: props.editor, + }); + + if (!props.clientRect) return; + + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: emojiConfig.placement ?? "bottom-start", + zIndex: 100, + }); + }, + + onUpdate(props: { + items: unknown[]; + command: unknown; + clientRect?: () => DOMRect; + }) { + if (component) { + component.updateProps({ + items: props.items, + command: props.command, + }); + } + + if (props.clientRect && popup?.[0]) { + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + } + }, + + onKeyDown(props: { event: KeyboardEvent }) { + if (props.event.key === "Escape") { + popup?.[0]?.hide(); + return true; + } + + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup?.[0]?.hide(); + handleSubmitRef.current(editorRef); + return true; + } + + return component?.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup?.[0]?.destroy(); + component?.destroy(); + component = null; + popup = null; + }, + }; + }, + }; + }, [emojiConfig]); + + const tipTapSlashConfig = useMemo(() => { + if (!slashConfig) return null; + + return { + char: slashConfig.char, + allowSpaces: slashConfig.allowSpaces ?? false, + allow: slashConfig.allow, + items: async ({ query }: { query: string }) => { + return await slashConfig.search(query); + }, + render: () => { + let component: ReactRenderer | null = null; + let popup: TippyInstance[] | null = null; + let editorRef: unknown = null; + + return { + onStart: (props: { + editor: unknown; + items: unknown[]; + command: unknown; + clientRect?: () => DOMRect; + }) => { + editorRef = props.editor; + component = new ReactRenderer(slashConfig.component as never, { + props: { + items: props.items, + command: props.command, + onClose: () => popup?.[0]?.hide(), + }, + editor: props.editor, + }); + + if (!props.clientRect) return; + + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: slashConfig.placement ?? "top-start", + zIndex: 100, + }); + }, + + onUpdate(props: { + items: unknown[]; + command: unknown; + clientRect?: () => DOMRect; + }) { + if (component) { + component.updateProps({ + items: props.items, + command: props.command, + }); + } + + if (props.clientRect && popup?.[0]) { + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + } + }, + + onKeyDown(props: { event: KeyboardEvent }) { + if (props.event.key === "Escape") { + popup?.[0]?.hide(); + return true; + } + + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup?.[0]?.hide(); + handleSubmitRef.current(editorRef); + return true; + } + + return component?.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup?.[0]?.destroy(); + component?.destroy(); + component = null; + popup = null; + }, + }; + }, + }; + }, [slashConfig]); // Build extensions array const extensions = useMemo(() => {