Skip to content

fix(suggestion): keep dismissed state after dismissal#7570

Open
bdbch wants to merge 3 commits intomainfrom
fix/suggestion-keep-dismissed-state
Open

fix(suggestion): keep dismissed state after dismissal#7570
bdbch wants to merge 3 commits intomainfrom
fix/suggestion-keep-dismissed-state

Conversation

@bdbch
Copy link
Member

@bdbch bdbch commented Mar 6, 2026

Changes Overview

After dismissing a suggestion via Escape, the suggestion menu would reappear as soon as the user typed another character in the same word. This makes dismissal feel broken — one Escape press should suppress the suggestion until the user clearly starts a
new input context.

Implementation Approach

Added a dismissedFrom: number | null field to the suggestion plugin's internal state. When the user dismisses via Escape (dispatching { exit: true } metadata), the trigger character's position (range.from) is stored in dismissedFrom.

On every subsequent transaction, before re-activating a match, the plugin checks:

  • Same word? — if match.range.from === dismissedFrom, the cursor is still in the dismissed word → stay suppressed
  • Whitespace/newline inserted? — if the transaction inserted any whitespace, the user has moved on → clear dismissedFrom
  • Different trigger position? — if the match starts at a different position, it's a different word → clear dismissedFrom
  • No match at all? — cursor has left any trigger context entirely → clear dismissedFrom

The key design decision: dismissedFrom intentionally survives the !next.active cleanup block at the bottom of apply(), so it persists across inactive transactions.

Testing Done

Added four new unit tests in suggestion.test.ts covering the full matrix:

  1. Typing more characters in the same word after dismissal → suggestion stays hidden
  2. Inserting a space after dismissal then typing a new @ → suggestion reopens
  3. Backspacing past the trigger char, then retyping it → suggestion reopens
  4. Typing a new @ at a different position after dismissal → suggestion reopens

Verification Steps

  1. Open an editor with the mention extension
  2. Type @foo — suggestion menu opens
  3. Press Escape — menu closes
  4. Type more characters (bar) — menu should not reappear (was broken before)
  5. Press Space, then type @ — menu should reappear ✓
  6. Repeat steps 2–3, then click somewhere else in the document and type @ — menu should reappear ✓

Additional Notes

No public API changes. The dismissedFrom field lives entirely in internal plugin state. The existing allow, shouldShow, and exitSuggestion API are unaffected.

Checklist

  • I have created a changeset for this PR if necessary.
  • My changes do not break the library.
  • I have added tests where applicable.
  • I have followed the project guidelines.
  • I have fixed any lint issues.

Related Issues

Follows up on #6833 (Escape key to dismiss suggestions).

Copilot AI review requested due to automatic review settings March 6, 2026 18:12
@bdbch bdbch self-assigned this Mar 6, 2026
@netlify
Copy link

netlify bot commented Mar 6, 2026

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit a6d2c40
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/69ab19e0cbb9c60008fb2ee9
😎 Deploy Preview https://deploy-preview-7570--tiptap-embed.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot
Copy link

changeset-bot bot commented Mar 6, 2026

🦋 Changeset detected

Latest commit: a6d2c40

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 72 packages
Name Type
@tiptap/suggestion Patch
@tiptap/extension-emoji Patch
@tiptap/extension-mention Patch
@tiptap/core Patch
@tiptap/extension-audio Patch
@tiptap/extension-blockquote Patch
@tiptap/extension-bold Patch
@tiptap/extension-bubble-menu Patch
@tiptap/extension-bullet-list Patch
@tiptap/extension-code-block-lowlight Patch
@tiptap/extension-code-block Patch
@tiptap/extension-code Patch
@tiptap/extension-collaboration-caret Patch
@tiptap/extension-collaboration Patch
@tiptap/extension-color Patch
@tiptap/extension-details Patch
@tiptap/extension-document Patch
@tiptap/extension-drag-handle-react Patch
@tiptap/extension-drag-handle-vue-2 Patch
@tiptap/extension-drag-handle-vue-3 Patch
@tiptap/extension-drag-handle Patch
@tiptap/extension-file-handler Patch
@tiptap/extension-floating-menu Patch
@tiptap/extension-font-family Patch
@tiptap/extension-hard-break Patch
@tiptap/extension-heading Patch
@tiptap/extension-highlight Patch
@tiptap/extension-horizontal-rule Patch
@tiptap/extension-image Patch
@tiptap/extension-invisible-characters Patch
@tiptap/extension-italic Patch
@tiptap/extension-link Patch
@tiptap/extension-list Patch
@tiptap/extension-mathematics Patch
@tiptap/extension-node-range Patch
@tiptap/extension-ordered-list Patch
@tiptap/extension-paragraph Patch
@tiptap/extension-strike Patch
@tiptap/extension-subscript Patch
@tiptap/extension-superscript Patch
@tiptap/extension-table-of-contents Patch
@tiptap/extension-table Patch
@tiptap/extension-text-align Patch
@tiptap/extension-text-style Patch
@tiptap/extension-text Patch
@tiptap/extension-twitch Patch
@tiptap/extension-typography Patch
@tiptap/extension-underline Patch
@tiptap/extension-unique-id Patch
@tiptap/extension-youtube Patch
@tiptap/extensions Patch
@tiptap/html Patch
@tiptap/markdown Patch
@tiptap/pm Patch
@tiptap/react Patch
@tiptap/starter-kit Patch
@tiptap/static-renderer Patch
@tiptap/vue-2 Patch
@tiptap/vue-3 Patch
@tiptap/extension-character-count Patch
@tiptap/extension-dropcursor Patch
@tiptap/extension-focus Patch
@tiptap/extension-gapcursor Patch
@tiptap/extension-history Patch
@tiptap/extension-list-item Patch
@tiptap/extension-list-keymap Patch
@tiptap/extension-placeholder Patch
@tiptap/extension-table-cell Patch
@tiptap/extension-table-header Patch
@tiptap/extension-table-row Patch
@tiptap/extension-task-item Patch
@tiptap/extension-task-list Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes @tiptap/suggestion dismissal behavior so exiting a suggestion (Escape / exitSuggestion) suppresses re-activation while the user continues typing in the same trigger context.

Changes:

  • Track dismissed trigger position in plugin state (dismissedFrom) and suppress activation when the match is in the same word.
  • Add whitespace-detection helper to clear dismissal when the user inserts whitespace/newlines.
  • Add unit tests for dismissal scenarios and a patch changeset entry.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
packages/suggestion/src/suggestion.ts Adds dismissedFrom state + activation suppression logic; introduces hasInsertedWhitespace helper.
packages/suggestion/src/tests/suggestion.test.ts Adds integration tests covering dismissal persistence and re-activation scenarios.
.changeset/fix-suggestion-dismissed-state-calm-river-flow.md Patch changeset documenting the user-visible fix.

Comment on lines +13 to +17
function hasInsertedWhitespace(transaction: Transaction): boolean {
if (!transaction.docChanged) {
return false
}
return transaction.steps.some(step => {
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.
Comment on lines +492 to +495
const sameWord = match.range.from === next.dismissedFrom
if (!sameWord || hasInsertedWhitespace(transaction)) {
next.dismissedFrom = null
}
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.
Comment on lines 484 to 487
text: match.text,
transaction,
}))
) {
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.

The code/comment indicates dismissal should clear on whitespace/newline insertion, but the new unit tests only cover spaces. Please add a test that simulates a newline/Enter (e.g. split block) after dismissal to lock in the intended behavior and avoid regressions.

Copilot generated this review using guidance from repository custom instructions.
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.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 6, 2026

Open in StackBlitz

@tiptap/extension-character-count

npm i https://pkg.pr.new/@tiptap/extension-character-count@7570

@tiptap/extension-dropcursor

npm i https://pkg.pr.new/@tiptap/extension-dropcursor@7570

@tiptap/extension-focus

npm i https://pkg.pr.new/@tiptap/extension-focus@7570

@tiptap/extension-gapcursor

npm i https://pkg.pr.new/@tiptap/extension-gapcursor@7570

@tiptap/extension-list-item

npm i https://pkg.pr.new/@tiptap/extension-list-item@7570

@tiptap/extension-history

npm i https://pkg.pr.new/@tiptap/extension-history@7570

@tiptap/extension-list-keymap

npm i https://pkg.pr.new/@tiptap/extension-list-keymap@7570

@tiptap/extension-placeholder

npm i https://pkg.pr.new/@tiptap/extension-placeholder@7570

@tiptap/extension-table-header

npm i https://pkg.pr.new/@tiptap/extension-table-header@7570

@tiptap/extension-table-cell

npm i https://pkg.pr.new/@tiptap/extension-table-cell@7570

@tiptap/extension-table-row

npm i https://pkg.pr.new/@tiptap/extension-table-row@7570

@tiptap/extension-task-item

npm i https://pkg.pr.new/@tiptap/extension-task-item@7570

@tiptap/extension-task-list

npm i https://pkg.pr.new/@tiptap/extension-task-list@7570

@tiptap/extension-audio

npm i https://pkg.pr.new/@tiptap/extension-audio@7570

@tiptap/core

npm i https://pkg.pr.new/@tiptap/core@7570

@tiptap/extension-bubble-menu

npm i https://pkg.pr.new/@tiptap/extension-bubble-menu@7570

@tiptap/extension-bold

npm i https://pkg.pr.new/@tiptap/extension-bold@7570

@tiptap/extension-blockquote

npm i https://pkg.pr.new/@tiptap/extension-blockquote@7570

@tiptap/extension-code

npm i https://pkg.pr.new/@tiptap/extension-code@7570

@tiptap/extension-bullet-list

npm i https://pkg.pr.new/@tiptap/extension-bullet-list@7570

@tiptap/extension-code-block

npm i https://pkg.pr.new/@tiptap/extension-code-block@7570

@tiptap/extension-collaboration

npm i https://pkg.pr.new/@tiptap/extension-collaboration@7570

@tiptap/extension-code-block-lowlight

npm i https://pkg.pr.new/@tiptap/extension-code-block-lowlight@7570

@tiptap/extension-collaboration-caret

npm i https://pkg.pr.new/@tiptap/extension-collaboration-caret@7570

@tiptap/extension-color

npm i https://pkg.pr.new/@tiptap/extension-color@7570

@tiptap/extension-details

npm i https://pkg.pr.new/@tiptap/extension-details@7570

@tiptap/extension-drag-handle

npm i https://pkg.pr.new/@tiptap/extension-drag-handle@7570

@tiptap/extension-document

npm i https://pkg.pr.new/@tiptap/extension-document@7570

@tiptap/extension-drag-handle-react

npm i https://pkg.pr.new/@tiptap/extension-drag-handle-react@7570

@tiptap/extension-drag-handle-vue-2

npm i https://pkg.pr.new/@tiptap/extension-drag-handle-vue-2@7570

@tiptap/extension-emoji

npm i https://pkg.pr.new/@tiptap/extension-emoji@7570

@tiptap/extension-drag-handle-vue-3

npm i https://pkg.pr.new/@tiptap/extension-drag-handle-vue-3@7570

@tiptap/extension-file-handler

npm i https://pkg.pr.new/@tiptap/extension-file-handler@7570

@tiptap/extension-floating-menu

npm i https://pkg.pr.new/@tiptap/extension-floating-menu@7570

@tiptap/extension-font-family

npm i https://pkg.pr.new/@tiptap/extension-font-family@7570

@tiptap/extension-heading

npm i https://pkg.pr.new/@tiptap/extension-heading@7570

@tiptap/extension-hard-break

npm i https://pkg.pr.new/@tiptap/extension-hard-break@7570

@tiptap/extension-highlight

npm i https://pkg.pr.new/@tiptap/extension-highlight@7570

@tiptap/extension-horizontal-rule

npm i https://pkg.pr.new/@tiptap/extension-horizontal-rule@7570

@tiptap/extension-image

npm i https://pkg.pr.new/@tiptap/extension-image@7570

@tiptap/extension-invisible-characters

npm i https://pkg.pr.new/@tiptap/extension-invisible-characters@7570

@tiptap/extension-italic

npm i https://pkg.pr.new/@tiptap/extension-italic@7570

@tiptap/extension-link

npm i https://pkg.pr.new/@tiptap/extension-link@7570

@tiptap/extension-list

npm i https://pkg.pr.new/@tiptap/extension-list@7570

@tiptap/extension-mathematics

npm i https://pkg.pr.new/@tiptap/extension-mathematics@7570

@tiptap/extension-mention

npm i https://pkg.pr.new/@tiptap/extension-mention@7570

@tiptap/extension-node-range

npm i https://pkg.pr.new/@tiptap/extension-node-range@7570

@tiptap/extension-ordered-list

npm i https://pkg.pr.new/@tiptap/extension-ordered-list@7570

@tiptap/extension-paragraph

npm i https://pkg.pr.new/@tiptap/extension-paragraph@7570

@tiptap/extension-subscript

npm i https://pkg.pr.new/@tiptap/extension-subscript@7570

@tiptap/extension-strike

npm i https://pkg.pr.new/@tiptap/extension-strike@7570

@tiptap/extension-superscript

npm i https://pkg.pr.new/@tiptap/extension-superscript@7570

@tiptap/extension-table

npm i https://pkg.pr.new/@tiptap/extension-table@7570

@tiptap/extension-text

npm i https://pkg.pr.new/@tiptap/extension-text@7570

@tiptap/extension-table-of-contents

npm i https://pkg.pr.new/@tiptap/extension-table-of-contents@7570

@tiptap/extension-text-align

npm i https://pkg.pr.new/@tiptap/extension-text-align@7570

@tiptap/extension-text-style

npm i https://pkg.pr.new/@tiptap/extension-text-style@7570

@tiptap/extension-twitch

npm i https://pkg.pr.new/@tiptap/extension-twitch@7570

@tiptap/extension-underline

npm i https://pkg.pr.new/@tiptap/extension-underline@7570

@tiptap/extension-unique-id

npm i https://pkg.pr.new/@tiptap/extension-unique-id@7570

@tiptap/extension-typography

npm i https://pkg.pr.new/@tiptap/extension-typography@7570

@tiptap/extension-youtube

npm i https://pkg.pr.new/@tiptap/extension-youtube@7570

@tiptap/html

npm i https://pkg.pr.new/@tiptap/html@7570

@tiptap/extensions

npm i https://pkg.pr.new/@tiptap/extensions@7570

@tiptap/markdown

npm i https://pkg.pr.new/@tiptap/markdown@7570

@tiptap/react

npm i https://pkg.pr.new/@tiptap/react@7570

@tiptap/pm

npm i https://pkg.pr.new/@tiptap/pm@7570

@tiptap/starter-kit

npm i https://pkg.pr.new/@tiptap/starter-kit@7570

@tiptap/static-renderer

npm i https://pkg.pr.new/@tiptap/static-renderer@7570

@tiptap/suggestion

npm i https://pkg.pr.new/@tiptap/suggestion@7570

@tiptap/vue-2

npm i https://pkg.pr.new/@tiptap/vue-2@7570

@tiptap/vue-3

npm i https://pkg.pr.new/@tiptap/vue-3@7570

commit: a6d2c40

@oBusk
Copy link

oBusk commented Mar 7, 2026

Will this implementation mean that adding/removing any character before the trigger. And then editing after the trigger again will activate it again? Maybe an acceptable compromise.

Are you also testing with allowSpaces: true?

@bdbch
Copy link
Member Author

bdbch commented Mar 7, 2026

This implementation is dismissing the mention state until you leave the mention via a whitespace or newline character or you move the selection out of the suggestion range. That means, if you insert characters right before your mention and go back in, you'll have the mention state again which I think is fair.

I think having support for allowSpaces makes it tricky to restore the suggestion state again except maybe using a newline.

If you want I could add an escape hatch to give you more control over when you restore the mention state again, but then you'd need to handle the logic yourself if the default isn't fitting your needs.

@oBusk
Copy link

oBusk commented Mar 9, 2026

I was considering how to solve it with allowSpaces, and there was some mentions of tracking the triggering position to identify "already closed", but the fact that something that could cause that trigger to move would reset and begins showing suggestions again made it slightly flimsy. I was considering if there could be some decoration on the triggering char to mark it as "consumed", but there might be other flimsyness with that.

Since we want allowSpaces for our usecase, I think the most reliable we can do is to try to trigger suggestions based on keydown rather than text content, but I understand that would mean we can't really use tiptap/suggestion to accomplish that.

This PR will definely make escape work more like you expect, especially without allowSpaces, so that's great! Could you clarify what control the escapehatch would give me? Are you saying I call some function to "reactivate" mention state manually?

@bdbch bdbch changed the base branch from develop to main March 14, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants