Feature Request: First-class async fetching, positioning, and UX patterns for @tiptap/suggestion
#7646
gethari
started this conversation in
Feature Requests
Replies: 1 comment 2 replies
-
|
@bdbch I can take up a PR for this, in-case if the team is ok in doing this |
Beta Was this translation helpful? Give feedback.
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Description
Overview
After building a production suggestion/mention system on top of
@tiptap/suggestion, we identified nine gaps between what the plugin provides and what a real-world async dropdown requires. Each gap forced us to implement workarounds insiderender()— a lifecycle hook that was never designed to be a data-fetching or UI management layer.This issue documents each gap as a distinct use case, the current workaround, and what a first-class API could look like. Addressing even a subset of these would significantly reduce the boilerplate every consumer has to write.
Gap 1 —
items()has no cancellation mechanism (stale results on fast typing)Problem
items()is called on every keystroke. Tiptap awaits each call and firesonUpdatefor every resolution — including stale ones from superseded queries. A user typing@johnfires 5 concurrentitems()calls; whichever resolves last wins, regardless of whether its query is still current.Workaround
Bypass
items()entirely, returnPromise.resolve([]), and manage all fetching insiderender()with a hand-rolledAbortController+debounce:Proposed API
Gap 2 — No popup positioning
Problem
Tiptap provides
clientRect(a getter for the decoration node's bounding rect) inSuggestionProps, but provides no mechanism to actually position a popup element relative to it. Every consumer must integrate their own positioning library.Workaround
Integrate Floating UI manually inside
render(), usingclientRectas the virtual anchor:Proposed API
A
placementoption and aonPositioncallback (or Floating UI middleware support) would let consumers skip the positioning boilerplate entirely:Gap 3 — Popup doesn't reposition on scroll or viewport resize
Problem
Even with custom positioning wired up, the popup becomes misaligned whenever the user scrolls the page or resizes the viewport while the suggestion is open. Tiptap does not emit any event for this.
Workaround
Manually attach
scroll(capture) andresizelisteners insideonStart, forward them to the positioning function, and remove them inonExit:Proposed API
If the plugin owns positioning (see Gap 2), repositioning on scroll/resize would be automatic. Alternatively, a
repositionOn?: string[]option listing DOM events that should trigger a reposition.Gap 4 — Popup renders into
document.body, breaking dialog/modal containmentProblem
Portaling the popup element into
document.bodyplaces it outside any<dialog>or Radix/Headless UI modal that contains the editor. This causes z-index conflicts and (in browsers that implement the top-layer for<dialog>) the popup appears behind the dialog's backdrop.Workaround
Walk up the DOM from
props.decorationNode(orprops.editor.view.dom) to find the nearest[role="dialog"]ancestor, and portal the popup inside that element instead:Proposed API
A
containeroption accepting anHTMLElementor a resolver function:Gap 5 — Escape key is intercepted by dialogs before ProseMirror sees it
Problem
handleKeyDownin the ProseMirror plugin runs at the editor's DOM level. A<dialog>element (or any overlay withkeydowncapture) interceptsEscapebefore it reaches ProseMirror, soonKeyDownis never called and the suggestion popup is never dismissed. The dialog closes instead.Workaround
Attach a
capture-phasekeydownlistener ondocumentthat interceptsEscapebefore any overlay:Proposed API
The plugin should attach its own capture-phase Escape listener so suggestions inside dialogs work out of the box, without consumers needing to hook into
documentthemselves.Gap 6 — No outside-click dismissal
Problem
Tiptap dismisses the suggestion when the editor loses focus (blur), but not when the user clicks on an unrelated part of the page while the editor retains focus (e.g. clicking a sidebar element scrolls the page but doesn't blur the editor). The popup stays open indefinitely.
Workaround
Attach a
capture-phasepointerdownlistener ondocumentand check whether the click target is outside both the popup element and the editor:Proposed API
A
dismissOnOutsideClick: booleanoption (defaulting totrue) that handles this automatically.Gap 7 — No
charLimit/ minimum-query-length gatingProblem
For large user directories or expensive APIs, it is not practical to fetch suggestions on a single character (e.g.
@apotentially matching thousands of users). There is no built-in way to suppress the fetch until the query reaches a minimum length, or to show the user a hint like "Type at least 2 characters to search".Workaround
Gate the
items()bypass manually: checkquery.length < charLimitbefore calling the debounced fetch, and push a different UI state to the renderer component:Proposed API
When
minQueryLengthis set and not yet met, Tiptap should passitems: []toonStart/onUpdatealong with a newqueryTooShort: trueflag inSuggestionPropsso renderers can show a hint.Gap 8 — No pre-populated / instant-open items
Problem
A common UX pattern is to show a pre-populated list immediately when the trigger character is typed (e.g.
@opens a dropdown with recently-mentioned users before any characters are typed). This requires items to be shown before any async fetch, instantly on trigger. There is no way to distinguishonStartwith an empty query fromonUpdatetriggered by the user typing and deleting back to empty.Workaround
Carry a separate
initialItemsconfig value, resolve it synchronously insideonStart, and inject it before the first fetch:Proposed API
SuggestionPropswould expose anisInitial: booleanflag so renderers can differentiate between "just opened" and "user has typed".Gap 9 — No loading state signal
Problem
Between the user typing a character and the
items()promise resolving, there is nothing inSuggestionPropsto indicate that a fetch is in progress. Renderers cannot show a spinner or skeleton without tracking this state themselves.Workaround
Manually update the renderer component with a
isLoading: trueprop immediately before calling the debounced fetch, andisLoading: falsein the callback:Proposed API
SuggestionPropsshould include aloading: booleanfield that istruefrom the momentitems()is called until it resolves:Summary Table
items()cancellation (stale results)AbortSignalinitems()props, ordebounceoptionplacement+offsetoptionscontainerresolver optiondismissOnOutsideClickoptioncharLimit/ minimum query lengthminQueryLengthoption +queryTooShortflaginitialItemsoption +isInitialflagloading: booleaninSuggestionPropsAll nine of these are currently implemented by consumers in
render()— a hook that was designed for UI lifecycle management, not data fetching or DOM event orchestration. First-class support for any subset would make@tiptap/suggestionsignificantly more self-contained for async use cases.Environment
@tiptap/suggestion:3.20.x@tiptap/core:3.xReactRenderer)Related
suggestion.ts—items()is awaited with no cancellationUse Case
All
@/#or suggestion decorations rendered in the editor.Type
New feature
Beta Was this translation helpful? Give feedback.
All reactions