fix: open editor rename after context menu releases focus (#18934)

* fix: open editor rename after context menu releases focus

* refactor(editor): tighten rename focus-handoff comments and test setup

Correct the rename-input focus comment that still credited the animation
frame with outrunning menu teardown, clarify why the rename now runs from
onCloseAutoFocus, and fold the repeated menu-close invocation in the tab
tests into one helper.
This commit is contained in:
Neil
2026-09-06 18:28:57 -07:00
committed by GitHub
parent 8d8b9dad78
commit deebe05ff0
4 changed files with 44 additions and 11 deletions
@@ -345,6 +345,15 @@ function findMenuItemByText(node: unknown, label: string): ReactElementLike {
return item
}
/** Picks Rename, then fires the close-autofocus that actually opens the input. */
function selectRenameFromMenu(node: unknown): void {
;(findMenuItemByText(node, 'Rename').props.onSelect as () => void)()
const content = findElementsByType(node, 'DropdownMenuContent')[0]!
;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({
preventDefault: vi.fn()
})
}
function findSpanByText(node: unknown, label: string): ReactElementLike {
const span = findElementsByType(node, 'span').find(
(candidate) =>
@@ -401,7 +410,7 @@ describe('EditorFileTab rename menu', () => {
// isUntitled; the tab menu must let users rename the screenshot-style
// "untitled-N.md" files directly.
expect(renameItem.props.disabled).toBe(false)
;(renameItem.props.onSelect as () => void)()
selectRenameFromMenu(firstRender)
const secondRender = expandNode((await renderEditorFileTab(file, onActivate)).element)
const inputs = findElementsByType(secondRender, 'input')
@@ -425,9 +434,8 @@ describe('EditorFileTab rename menu', () => {
it('ignores IME composition Enter before renaming the editor file tab', async () => {
const file = baseFile()
const firstRender = expandNode((await renderEditorFileTab(file)).element)
const renameItem = findMenuItemByText(firstRender, 'Rename')
;(renameItem.props.onSelect as () => void)()
selectRenameFromMenu(firstRender)
const secondRender = expandNode((await renderEditorFileTab(file)).element)
const input = findElementsByType(secondRender, 'input')[0]
@@ -457,9 +465,8 @@ describe('EditorFileTab rename menu', () => {
it('does not re-commit when unmounting the rename input emits multiple blur events', async () => {
const file = baseFile()
const firstRender = expandNode((await renderEditorFileTab(file)).element)
const renameItem = findMenuItemByText(firstRender, 'Rename')
;(renameItem.props.onSelect as () => void)()
selectRenameFromMenu(firstRender)
const secondRender = expandNode((await renderEditorFileTab(file)).element)
const input = findElementsByType(secondRender, 'input')[0]
@@ -171,8 +171,8 @@ export default function EditorFileTab({
if (!input) {
return
}
// Why: Radix closes the context menu after onSelect; defer focus so its
// teardown cannot steal focus back or blur-commit the newly mounted input.
// Why: the tab re-lays out around the input; focus on the next frame so
// that swap has settled before selecting text.
renameFocusFrameRef.current = requestAnimationFrame(() => {
renameFocusFrameRef.current = null
if (renameInputRef.current !== input) {
@@ -202,7 +202,9 @@ function extractText(node: unknown): string {
return el.props && 'children' in el.props ? extractText(el.props.children) : ''
}
async function renderMenu(): Promise<unknown> {
async function renderMenu(
overrides: { onActivate?: () => void; onOpenRenameInput?: () => void } = {}
): Promise<unknown> {
const module = await import('./EditorFileTabContextMenu')
return module.EditorFileTabContextMenu({
open: true,
@@ -238,7 +240,8 @@ async function renderMenu(): Promise<unknown> {
onCloseAll: vi.fn(),
onCloseToRight: vi.fn(),
onCloseToLeft: vi.fn(),
onOpenMarkdownPreview: vi.fn()
onOpenMarkdownPreview: vi.fn(),
...overrides
})
}
@@ -266,6 +269,27 @@ describe('EditorFileTabContextMenu close-all shortcut', () => {
vi.unstubAllGlobals()
})
it('opens rename only after menu close releases focus and consumes the request once', async () => {
const onActivate = vi.fn()
const onOpenRenameInput = vi.fn()
const tree = expandNode(await renderMenu({ onActivate, onOpenRenameInput }))
const rename = findElementsByType(tree, 'DropdownMenuItem').find((item) =>
extractText(item.props.children).includes('Rename')
)!
const content = findElementsByType(tree, 'DropdownMenuContent')[0]!
;(rename.props.onSelect as () => void)()
expect(onActivate).not.toHaveBeenCalled()
expect(onOpenRenameInput).not.toHaveBeenCalled()
const preventDefault = vi.fn()
const close = content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void
close({ preventDefault })
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(onActivate).toHaveBeenCalledTimes(1)
expect(onOpenRenameInput).toHaveBeenCalledTimes(1)
close({ preventDefault })
expect(onOpenRenameInput).toHaveBeenCalledTimes(1)
})
it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => {
const tree = expandNode(await renderMenu())
const menuItems = findElementsByType(tree, 'DropdownMenuItem')
@@ -126,6 +126,10 @@ export function EditorFileTabContextMenu({
}
skipMenuFocusRestoreRef.current = false
event.preventDefault()
// Why: opening the input in onSelect lets the still-closing menu reclaim
// focus, and the resulting blur commits the rename away before the user types.
onActivate()
onOpenRenameInput()
}}
>
<TabWorkspaceLayoutMenuSection
@@ -137,8 +141,6 @@ export function EditorFileTabContextMenu({
disabled={!canRename || isRenaming}
onSelect={() => {
skipMenuFocusRestoreRef.current = true
onActivate()
onOpenRenameInput()
}}
>
<Pencil className="size-3.5" />