Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/new-carrots-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tiptap/suggestion': patch
---

Fixed a bug where `renderer.onExit` was called up to 3 times instead of once when pressing Escape to close an active suggestion popup.

**Note:** When exiting a suggestion via Escape or `exitSuggestion()`, `onExit` is now called from the `view.update` lifecycle after the plugin state has been applied and the DOM updated. This means `decorationNode` will be `null` inside `onExit` when exiting via Escape, as the decoration is removed from the DOM before `onExit` is invoked. If you rely on `decorationNode` in `onExit` (e.g. to position a closing animation), handle Escape manually in `onKeyDown`, capture the node there, call `exitSuggestion(view, pluginKey)` yourself, and return `true` to prevent the plugin from also dispatching the exit.
7 changes: 0 additions & 7 deletions demos/src/Examples/Community/React/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
reactRenderer.destroy()
reactRenderer.element.remove()

return true
}

return reactRenderer.ref?.onKeyDown(props)
},

Expand Down
7 changes: 0 additions & 7 deletions demos/src/Examples/Community/Vue/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()
component.element.remove()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
12 changes: 0 additions & 12 deletions demos/src/Examples/MultiMention/React/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,6 @@ export default [
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down Expand Up @@ -141,12 +135,6 @@ export default [
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
12 changes: 0 additions & 12 deletions demos/src/Examples/MultiMention/Vue/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,6 @@ export default [
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down Expand Up @@ -150,12 +144,6 @@ export default [
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
7 changes: 0 additions & 7 deletions demos/src/Experiments/Commands/Vue/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()
component.element.remove()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
7 changes: 0 additions & 7 deletions demos/src/Nodes/Emoji/React/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
document.body.removeChild(component.element)
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
7 changes: 0 additions & 7 deletions demos/src/Nodes/Emoji/Vue/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
document.body.removeChild(component.element)
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
6 changes: 0 additions & 6 deletions demos/src/Nodes/Mention/React/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
6 changes: 0 additions & 6 deletions demos/src/Nodes/Mention/Vue/suggestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,6 @@ export default {
},

onKeyDown(props) {
if (props.event.key === 'Escape') {
component.destroy()

return true
}

return component.ref?.onKeyDown(props)
},

Expand Down
48 changes: 48 additions & 0 deletions packages/suggestion/src/__tests__/suggestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,52 @@ describe('suggestion integration', () => {

editor.destroy()
})

it('should call onExit exactly once when Escape is pressed', async () => {
const onExit = vi.fn()
const onStart = vi.fn()
const items = vi.fn().mockReturnValue([])

const MentionExtension = Extension.create({
name: 'mention-escape',
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
items,
render: () => ({
onStart,
onExit,
}),
}),
]
},
})

const editor = new Editor({
extensions: [StarterKit, MentionExtension],
content: '<p></p>',
})

editor.chain().insertContent('@').run()

// Flush microtasks because plugin view update is async
await Promise.resolve()

expect(onStart).toHaveBeenCalledTimes(1)

// Simulate pressing Escape on the editor DOM element
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })

editor.view.dom.dispatchEvent(escapeEvent)

// Flush microtasks
await Promise.resolve()

// onExit should be called exactly once, not multiple times
expect(onExit).toHaveBeenCalledTimes(1)

editor.destroy()
})
})
88 changes: 16 additions & 72 deletions packages/suggestion/src/suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ export interface SuggestionKeyDownProps {

export const SuggestionPluginKey = new PluginKey('suggestion')

/**
* Programmatically exit a suggestion plugin by dispatching a metadata-only
* transaction. This is the safe, recommended API to remove suggestion
* decorations without touching the document or causing mapping errors.
*/
export function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
view.dispatch(tr)
}

/**
* This utility allows you to create suggestions.
* @see https://tiptap.dev/api/utilities/suggestion
Expand Down Expand Up @@ -257,38 +267,6 @@ export function Suggestion<I = any, TSelected = any>({
return currentDecorationNode?.getBoundingClientRect() || null
}
}
// small helper used internally by the view to dispatch an exit
function dispatchExit(view: EditorView, pluginKeyRef: PluginKey) {
try {
const state = pluginKey.getState(view.state)
const decorationNode = state?.decorationId
? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
: null

const exitProps: SuggestionProps = {
// @ts-ignore editor is available in closure
editor,
range: state?.range || { from: 0, to: 0 },
query: state?.query || null,
text: state?.text || null,
items: [],
command: commandProps => {
return command({ editor, range: state?.range || { from: 0, to: 0 }, props: commandProps as any })
},
decorationNode,
clientRect: clientRectFor(view, decorationNode),
}

renderer?.onExit?.(exitProps)
} catch {
// ignore errors from consumer renderers
}

const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
// Dispatch a metadata-only transaction to signal the plugin to exit
view.dispatch(tr)
}

const plugin: Plugin<any> = new Plugin({
key: pluginKey,

Expand Down Expand Up @@ -491,51 +469,27 @@ export function Suggestion<I = any, TSelected = any>({
return false
}

// If Escape is pressed, call onExit and dispatch a metadata-only
// If Escape is pressed, dispatch a metadata-only
// transaction to unset the suggestion state. This provides a safe
// and deterministic way to exit the suggestion without altering the
// document (avoids transaction mapping/mismatch issues).
if (event.key === 'Escape' || event.key === 'Esc') {
const state = plugin.getState(view.state)
const cachedNode = props?.decorationNode ?? null
const decorationNode =
cachedNode ??
(state?.decorationId ? view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) : null)

// Give the consumer a chance to handle Escape via onKeyDown first.
// If the consumer returns `true` we assume they handled the event and
// we won't call onExit/dispatchExit so they can both prevent
// we won't call exitSuggestion so they can both prevent
// propagation and decide whether to close the suggestion themselves.
const handledByKeyDown = renderer?.onKeyDown?.({ view, event, range: state.range }) || false

if (handledByKeyDown) {
return true
}

const exitProps: SuggestionProps = {
editor,
range: state.range,
query: state.query,
text: state.text,
items: [],
command: commandProps => {
return command({ editor, range: state.range, props: commandProps as any })
},
decorationNode,
// If we have a cached decoration node, use it for the clientRect
// to avoid another DOM lookup. If not, leave clientRect null and
// let consumer decide if they want to query.
clientRect: decorationNode
? () => {
return decorationNode.getBoundingClientRect() || null
}
: null,
}

renderer?.onExit?.(exitProps)

// dispatch metadata-only transaction to unset the plugin state
dispatchExit(view, pluginKey)
// dispatch metadata-only transaction to unset the plugin state;
// the view.update lifecycle will call onExit when it detects
// the active → inactive state transition.
exitSuggestion(view, pluginKey)

return true
}
Expand Down Expand Up @@ -573,13 +527,3 @@ export function Suggestion<I = any, TSelected = any>({

return plugin
}

/**
* Programmatically exit a suggestion plugin by dispatching a metadata-only
* transaction. This is the safe, recommended API to remove suggestion
* decorations without touching the document or causing mapping errors.
*/
export function exitSuggestion(view: EditorView, pluginKeyRef: PluginKey = SuggestionPluginKey) {
const tr = view.state.tr.setMeta(pluginKeyRef, { exit: true })
view.dispatch(tr)
}