fix: memoize TipTap suggestion configs to ensure stable references

The fundamental issue was that createSuggestionConfig() was called inside
the extensions useMemo, creating NEW TipTap config objects with NEW function
references on every extensions rebuild. TipTap likely compares these configs
by reference to manage its internal suggestion state, so constantly recreating
them broke the autocomplete system.

Solution:
1. Extract individual suggestion configs with useMemo (mentionConfig, emojiConfig, slashConfig)
2. Create separate useMemos for the TipTap configs (tipTapMentionConfig, tipTapEmojiConfig, tipTapSlashConfig)
3. Use these stable TipTap configs in the extensions useMemo
4. Update extensions dependencies to include both the source configs and TipTap configs

This ensures:
- TipTap configs are only recreated when the source suggestion configs change
- Same object references are passed to TipTap across renders
- Suggestion state (popup, search results) remains intact

This follows the same pattern as the main branch, which creates stable
suggestion configs via useMemo before passing them to extensions.
This commit is contained in:
Claude
2026-01-20 18:17:44 +00:00
parent d9d2233960
commit 39a9c71da7

View File

@@ -543,13 +543,46 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
handleSubmitRef.current = handleSubmit;
// Extract and memoize individual suggestion configs to avoid unnecessary recalculations
const mentionConfig = useMemo(
() => suggestions.find((s) => s.char === "@"),
[suggestions],
);
const emojiConfig = useMemo(
() => suggestions.find((s) => s.char === ":"),
[suggestions],
);
const slashConfig = useMemo(
() => suggestions.find((s) => s.char === "/"),
[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],
);
// Build extensions array
const extensions = useMemo(() => {
// Find suggestion configs inside useMemo to avoid recreating extensions on every render
const mentionConfig = suggestions.find((s) => s.char === "@");
const emojiConfig = suggestions.find((s) => s.char === ":");
const slashConfig = suggestions.find((s) => s.char === "/");
const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0;
// Custom extension for keyboard shortcuts
@@ -592,12 +625,12 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
];
// Add mention extension for @ mentions
if (mentionConfig) {
if (tipTapMentionConfig && mentionConfig) {
exts.push(
Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: {
...createSuggestionConfig(mentionConfig, handleSubmitRef),
...tipTapMentionConfig,
command: ({
editor,
range,
@@ -635,12 +668,12 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
}
// Add emoji extension
if (emojiConfig) {
if (tipTapEmojiConfig && emojiConfig) {
exts.push(
EmojiMention.configure({
HTMLAttributes: { class: "emoji" },
suggestion: {
...createSuggestionConfig(emojiConfig, handleSubmitRef),
...tipTapEmojiConfig,
command: ({
editor,
range,
@@ -675,13 +708,13 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
}
// Add slash command extension
if (slashConfig) {
if (tipTapSlashConfig && slashConfig) {
const SlashCommand = Mention.extend({ name: "slashCommand" });
exts.push(
SlashCommand.configure({
HTMLAttributes: { class: "slash-command" },
suggestion: {
...createSuggestionConfig(slashConfig, handleSubmitRef),
...tipTapSlashConfig,
command: ({
editor,
props,
@@ -711,7 +744,17 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
}
return exts;
}, [submitBehavior, placeholder, blobPreview, suggestions]);
}, [
submitBehavior,
placeholder,
blobPreview,
mentionConfig,
emojiConfig,
slashConfig,
tipTapMentionConfig,
tipTapEmojiConfig,
tipTapSlashConfig,
]);
const editor = useEditor({
extensions,