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
5 changes: 5 additions & 0 deletions .changeset/fix-suggestion-dismissed-state-calm-river-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/suggestion": patch
---

Suggestions dismissed via Escape no longer reappear when the user keeps typing in the same word — they only come back after inserting whitespace, a newline, or moving the cursor to a different trigger.
121 changes: 120 additions & 1 deletion packages/suggestion/src/__tests__/suggestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Editor, Extension } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { describe, expect, it, vi } from 'vitest'

import { Suggestion } from '../suggestion.js'
import { exitSuggestion, Suggestion, SuggestionPluginKey } from '../suggestion.js'
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import formatting looks off (exitSuggestion,Suggestion missing a space after the comma) and doesn’t match the surrounding style; running the formatter should normalize this.

Suggested change
import { exitSuggestion, Suggestion, SuggestionPluginKey } from '../suggestion.js'
import {
exitSuggestion,
Suggestion,
SuggestionPluginKey,
} from '../suggestion.js'

Copilot uses AI. Check for mistakes.

describe('suggestion integration', () => {
it('should respect shouldShow returning false', async () => {
Expand Down Expand Up @@ -125,3 +125,122 @@ describe('suggestion integration', () => {
editor.destroy()
})
})

describe('suggestion dismissal', () => {
/** Builds a minimal editor with a single @-mention suggestion and returns helpers. */
function setup() {
const onStart = vi.fn()
const onUpdate = vi.fn()
const onExit = vi.fn()

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

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

return { editor, onStart, onUpdate, onExit }
}

it('does not re-open the suggestion when the user keeps typing in the same word after dismissal', async () => {
const { editor, onStart, onUpdate } = setup()

// Trigger suggestion
editor.chain().insertContent('@fo').run()
await Promise.resolve()
expect(onStart).toHaveBeenCalledTimes(1)

// Dismiss via exitSuggestion (same as pressing Escape)
exitSuggestion(editor.view, SuggestionPluginKey)
await Promise.resolve()

const startCallsBefore = onStart.mock.calls.length
const updateCallsBefore = onUpdate.mock.calls.length

// Keep typing in the same word
editor.chain().insertContent('o').run()
await Promise.resolve()

expect(onStart.mock.calls.length).toBe(startCallsBefore)
expect(onUpdate.mock.calls.length).toBe(updateCallsBefore)

editor.destroy()
})

it('re-opens the suggestion after a space is inserted following dismissal', async () => {
const { editor, onStart } = setup()

// Trigger and dismiss
editor.chain().insertContent('@foo').run()
await Promise.resolve()
exitSuggestion(editor.view, SuggestionPluginKey)
await Promise.resolve()

const startCallsBefore = onStart.mock.calls.length

// Space clears dismissed state; typing a new @ afterwards should open suggestion
editor.chain().insertContent(' @').run()
await Promise.resolve()

expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)

editor.destroy()
})

it('re-opens the suggestion when the trigger char is deleted and retyped', async () => {
const { editor, onStart } = setup()

// Trigger and dismiss
editor.chain().insertContent('@').run()
await Promise.resolve()
exitSuggestion(editor.view, SuggestionPluginKey)
await Promise.resolve()

const startCallsBefore = onStart.mock.calls.length

// Delete the @ — cursor leaves trigger context, dismissedFrom clears
editor.commands.deleteRange({ from: 1, to: 2 })
await Promise.resolve()

// Retype @
editor.chain().insertContent('@').run()
await Promise.resolve()

expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)

editor.destroy()
})

it('re-opens the suggestion when a different trigger is typed elsewhere', async () => {
const { editor, onStart } = setup()

// Trigger and dismiss at first @
editor.chain().insertContent('@foo').run()
await Promise.resolve()
exitSuggestion(editor.view, SuggestionPluginKey)
await Promise.resolve()

const startCallsBefore = onStart.mock.calls.length

// Move to a new word and type a fresh @
editor.chain().insertContent(' @').run()
await Promise.resolve()

expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore)

editor.destroy()
})
})
56 changes: 51 additions & 5 deletions packages/suggestion/src/suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view'

import { findSuggestionMatch as defaultFindSuggestionMatch } from './findSuggestionMatch.js'

/**
* Returns true if the transaction inserted any whitespace or newline character.
* Used to determine when a dismissed suggestion should become active again.
*/
function hasInsertedWhitespace(transaction: Transaction): boolean {
if (!transaction.docChanged) {
return false
}
return transaction.steps.some(step => {
Comment on lines +13 to +17
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasInsertedWhitespace relies on (step as any).slice, which bypasses type safety and is brittle across different ProseMirror Step types. Consider narrowing to ReplaceStep/ReplaceAroundStep (as done elsewhere in the repo) before reading slice, so this helper stays correct and typed as ProseMirror evolves.

Copilot uses AI. Check for mistakes.
const slice = (step as any).slice
if (!slice?.content) {
return false
}
// textBetween with '\n' as block separator catches both inline spaces and newlines
const inserted = slice.content.textBetween(0, slice.content.size, '\n')
return /\s/.test(inserted)
})
}

export interface SuggestionOptions<I = any, TSelected = any> {
/**
* The plugin key for the suggestion plugin.
Expand Down Expand Up @@ -381,6 +400,9 @@ export function Suggestion<I = any, TSelected = any>({
text: null | string
composing: boolean
decorationId?: string | null
/** Position of the trigger char when the suggestion was dismissed via Escape.
* Non-null means "stay dismissed until the user leaves this word or inserts whitespace". */
dismissedFrom: number | null
} = {
active: false,
range: {
Expand All @@ -390,6 +412,7 @@ export function Suggestion<I = any, TSelected = any>({
query: null,
text: null,
composing: false,
dismissedFrom: null,
}

return state
Expand All @@ -414,6 +437,10 @@ export function Suggestion<I = any, TSelected = any>({
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
// Remember where the dismissed suggestion was so we can suppress re-activation
// within the same word. If somehow exit fires without an active suggestion, carry
// the existing dismissedFrom forward so it isn't accidentally cleared.
next.dismissedFrom = prev.active ? prev.range.from : prev.dismissedFrom

return next
}
Expand Down Expand Up @@ -458,12 +485,31 @@ export function Suggestion<I = any, TSelected = any>({
transaction,
}))
) {
next.active = true
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
next.range = match.range
next.query = match.query
next.text = match.text
// Resolve dismissed state before activating.
// Un-dismiss when: the match is at a different trigger position (different word),
// or the user inserted whitespace / a newline (deliberate continuation of input).
if (next.dismissedFrom !== null) {
const sameWord = match.range.from === next.dismissedFrom
if (!sameWord || hasInsertedWhitespace(transaction)) {
next.dismissedFrom = null
}
Comment on lines +492 to +495
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dismissedFrom stores a document position but isn’t mapped through transaction.mapping on document changes. If content is inserted/deleted before the trigger (including remote/collab changes), the stored position can drift and the sameWord comparison can become incorrect. Map dismissedFrom forward when transaction.docChanged (and clear it if it was deleted) before comparing to match.range.from.

Suggested change
const sameWord = match.range.from === next.dismissedFrom
if (!sameWord || hasInsertedWhitespace(transaction)) {
next.dismissedFrom = null
}
// Keep dismissedFrom in sync with document changes so comparisons to
// match.range.from stay correct even when content is inserted/removed
// before the trigger position (including remote/collab changes).
if (transaction.docChanged) {
const mapped = transaction.mapping.mapResult(next.dismissedFrom)
next.dismissedFrom = mapped.deleted ? null : mapped.pos
}
if (next.dismissedFrom !== null) {
const sameWord = match.range.from === next.dismissedFrom
if (!sameWord || hasInsertedWhitespace(transaction)) {
next.dismissedFrom = null
}
}

Copilot uses AI. Check for mistakes.
}

if (next.dismissedFrom === null) {
next.active = true
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
// No match means the cursor has left any trigger context entirely —
// safe to forget the dismissed position so the next trigger starts fresh.
if (!match) {
next.dismissedFrom = null
}
next.active = false
}
} else {
Expand Down
Loading