Skip to content

[suggestions] Fix multiple onExit calls when using escape to close#7592

Open
oBusk wants to merge 5 commits intoueberdosis:mainfrom
oBusk:double-exit-on-escape
Open

[suggestions] Fix multiple onExit calls when using escape to close#7592
oBusk wants to merge 5 commits intoueberdosis:mainfrom
oBusk:double-exit-on-escape

Conversation

@oBusk
Copy link

@oBusk oBusk commented Mar 13, 2026

Changes Overview

Hitting escape when suggestion is open no longer triggers multiple calls to renderer.onExit(), and behaves more in line with other onExit() calls.

Implementation Approach

Looking at the code for handling escape presses, it initially called renderer.onExit() directly, and then continued to call dispatchExit() which in turn also called renderer.onExit() directly, before finally dispatching the meta-only transaction which reaches view.update and triggers the baseline onExit() logic.

I added a test which simply triggers escape and counts the number of times onExit() is called, and initially it was triggered 3 times.

This change removes the extra calls to renderer.onExit() and letting the "default" path of handling it in view.update go through, triggering onExit() exactly once, regardless of how the suggestions are closed.

Note: the initial onExit() call would include the decorationNode element to the onExit() listener, but I'm making the assumption that this is not strictly necessary since the element itself will be removed within the same execution once the transaction goes through, so you can't really use the element for a lot. You will still get clientRect if you need to position something relating to the canceled suggestion. Also no other scenario of closing suggestions will give onExit() the decorationNode. If this slight change in behaviour is something we want to avoid, I can look at trying to pass it before exiting.

I also updated all exampels to no longer use their own escape handling. I assume they had this before the general escape handler was built into suggestion. The fact that they all handled escape on their own meant that they

  1. Circumvented the bug of triggering onExit multiple times on exit.
  2. Left the editors in a bad state with the decorators still active etc.
    If we want the exampels to handle escape on their own, they're probably better of using exitSuggestion() utility instead.

Testing Done

A test was added to make sure that triggering escape triggers onExit a single time.

All the touched exampels was manually tested

Verification Steps

Verify that exiting suggestions using escape, or exitSuggestion() works as expected.

Consider if it's likely that consumers rely on decorationNode in onExit() specifically for escape presses.

Additional Notes

Related to: #6833, #6908, #6910

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

oBusk added 4 commits March 13, 2026 11:07
The escape code called onExit directly, then called dispatchExit which in turn called onExit directly, and finally once the exit signal is received in view.update, is called again. This meant that the onExit callback was being called up to 3 times when escape was used to close it.

*Affecting change*: Previously `onExit` would receive the `decorationNode` right before it was removed when hitting escape. This was not the case if the suggestion was exited any other way (Moving cursor out from trigger, calling `exitSuggestion()`, etc.), so the current behaviour is more consistent across all exit methods.
The suggestion plugin now correctly handles Escape via exitSuggestion,
which triggers the view.update lifecycle and calls onExit once. The
demos were previously handling Escape in onKeyDown and returning true,
which bypassed the plugin's exit mechanism and left it in a stale
active state. Now Escape falls through to the plugin naturally.
Copilot AI review requested due to automatic review settings March 13, 2026 13:41
@changeset-bot
Copy link

changeset-bot bot commented Mar 13, 2026

🦋 Changeset detected

Latest commit: e52986f

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

@netlify
Copy link

netlify bot commented Mar 13, 2026

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit e52986f
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/69b41835350f980008292b5f
😎 Deploy Preview https://deploy-preview-7592--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.

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

This PR fixes @tiptap/suggestion so pressing Escape to close an active suggestion triggers renderer.onExit() exactly once (instead of multiple times), and aligns Escape handling with the standard exit path driven by the plugin view lifecycle.

Changes:

  • Removed direct/manual renderer.onExit() calls from the Escape key path and rely on the plugin state transition (exitSuggestionview.update) to fire onExit once.
  • Added an integration test asserting onExit is called exactly once on Escape.
  • Updated multiple demos to stop manually handling Escape (so they don’t bypass the plugin exit logic).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/suggestion/src/suggestion.ts Removes the extra Escape-path onExit calls and routes Escape through exitSuggestion so onExit is emitted via the plugin view lifecycle.
packages/suggestion/src/tests/suggestion.test.ts Adds a regression test ensuring Escape triggers onExit exactly once.
demos/src/Nodes/Mention/Vue/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Nodes/Mention/React/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Nodes/Emoji/Vue/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Nodes/Emoji/React/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Experiments/Commands/Vue/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Examples/MultiMention/Vue/suggestions.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Examples/MultiMention/React/suggestions.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Examples/Community/Vue/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
demos/src/Examples/Community/React/suggestion.js Removes demo-level Escape handling and delegates to the suggestion plugin lifecycle.
.changeset/new-carrots-hide.md Documents the user-facing bugfix and behavioral note for onExit on Escape.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 262 to 267
return () => {
const state = pluginKey.getState(editor.state)
const decorationId = state?.decorationId
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)

return currentDecorationNode?.getBoundingClientRect() || null
Copy link
Author

Choose a reason for hiding this comment

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

Again, onExit() typically does not send a decorationNode, and there is already code to use clientRect from the anchor when decorationNode is missing, so it should be fine.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@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.

2 participants