Skip to content

Commit 5fabce4

Browse files
committed
test(rich-editor): cover suggestion keyboard nav through ReactRenderer; drop inline comments
Adds a test that drives the real ReactRenderer path the suggestion plugin uses: the captured onKeyDown handle returns false while the store is empty and true once async workspace items land, and arrow+enter select the right item. Removes the explanatory inline comments from the two imperative handles.
1 parent 91b6a41 commit 5fabce4

3 files changed

Lines changed: 96 additions & 5 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Guards the `@` menu's keyboard navigation against the async-data race: the suggestion plugin grabs
5+
* the list's `onKeyDown` handle once, but workspace items arrive later via the store. The handle must
6+
* read live values so arrow/enter work after the data lands (otherwise keys fall through to the editor).
7+
* The second test drives the real `ReactRenderer` path the suggestion plugin actually uses.
8+
*/
9+
import { act, createRef } from 'react'
10+
import { Editor } from '@tiptap/core'
11+
import { EditorContent, ReactRenderer } from '@tiptap/react'
12+
import { File } from 'lucide-react'
13+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14+
import { createMarkdownEditorExtensions } from '../extensions'
15+
import { MentionList, type MentionListHandle } from './mention-list'
16+
import { createMentionStore } from './mention-store'
17+
import type { MentionItem } from './types'
18+
19+
const items: MentionItem[] = [
20+
{ kind: 'file', id: 'a', label: 'Alpha', group: 'Files', icon: File },
21+
{ kind: 'file', id: 'b', label: 'Beta', group: 'Files', icon: File },
22+
]
23+
24+
const arrowDown = { event: new KeyboardEvent('keydown', { key: 'ArrowDown' }) }
25+
const enter = { event: new KeyboardEvent('keydown', { key: 'Enter' }) }
26+
27+
describe('MentionList keyboard nav', () => {
28+
let container: HTMLElement
29+
let root: ReturnType<typeof import('react-dom/client').createRoot>
30+
31+
beforeEach(async () => {
32+
// jsdom implements neither — both are exercised by scroll-into-view and ProseMirror.
33+
Element.prototype.scrollIntoView = vi.fn()
34+
document.elementFromPoint = vi.fn(() => null)
35+
const { createRoot } = await import('react-dom/client')
36+
container = document.createElement('div')
37+
document.body.appendChild(container)
38+
root = createRoot(container)
39+
})
40+
41+
afterEach(() => {
42+
act(() => root.unmount())
43+
container.remove()
44+
})
45+
46+
it('navigates with arrows + inserts on enter once async items have loaded', () => {
47+
const ref = createRef<MentionListHandle>()
48+
const command = vi.fn()
49+
const store = createMentionStore()
50+
51+
// Menu opens before the workspace data resolves — the store is still empty.
52+
act(() => {
53+
root.render(<MentionList ref={ref} query='' command={command} store={store} />)
54+
})
55+
expect(ref.current?.onKeyDown(arrowDown)).toBe(false)
56+
57+
// Async data lands; the captured handle must now see the items and intercept the keys.
58+
act(() => store.set(items))
59+
60+
let handled: boolean | undefined
61+
act(() => {
62+
handled = ref.current?.onKeyDown(arrowDown)
63+
})
64+
expect(handled).toBe(true)
65+
66+
act(() => {
67+
ref.current?.onKeyDown(enter)
68+
})
69+
expect(command).toHaveBeenCalledWith(items[1])
70+
})
71+
72+
it('exposes a working onKeyDown through ReactRenderer (the suggestion plugin path)', async () => {
73+
const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) })
74+
act(() => {
75+
root.render(<EditorContent editor={editor} />)
76+
})
77+
78+
const command = vi.fn()
79+
const store = createMentionStore()
80+
const renderer = new ReactRenderer<MentionListHandle>(MentionList, {
81+
editor,
82+
props: { query: '', command, store },
83+
})
84+
// Let the portal mount so ReactRenderer captures the imperative handle.
85+
await act(async () => {})
86+
87+
expect(renderer.ref).not.toBeNull()
88+
expect(renderer.ref?.onKeyDown(arrowDown)).toBe(false)
89+
90+
act(() => store.set(items))
91+
expect(renderer.ref?.onKeyDown(arrowDown)).toBe(true)
92+
93+
renderer.destroy()
94+
editor.destroy()
95+
})
96+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-list.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,6 @@ export const MentionList = forwardRef<MentionListHandle, MentionListProps>(funct
9090
?.scrollIntoView({ block: 'nearest' })
9191
}, [activeIndex])
9292

93-
// The suggestion plugin captures this handle via `ReactRenderer.ref` once at mount, so it must read
94-
// live values rather than close over them — otherwise keyboard nav uses the initial (empty) `flat`
95-
// from before the async workspace data landed, and arrow keys fall through to the editor.
9693
const latest = useRef({ flat, activeIndex, command })
9794
latest.current = { flat, activeIndex, command }
9895

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command-list.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ export const SlashCommandList = forwardRef<SlashCommandListHandle, SlashCommandL
3737
?.scrollIntoView({ block: 'nearest' })
3838
}, [activeIndex])
3939

40-
// Read live values: the suggestion plugin captures this handle via `ReactRenderer.ref` at mount,
41-
// so closing over `items`/`activeIndex` would make Enter act on the mount-time snapshot.
4240
const latest = useRef({ items, activeIndex, command })
4341
latest.current = { items, activeIndex, command }
4442

0 commit comments

Comments
 (0)