fix: retire native edit menus when their renderer document ends (#35383)

* fix: retire editable context menus with renderer documents

* fix: retire edit menus opened during a pending reload at commit

A right-click while a cross-document reload is pending opens a menu after
the navigation fence fired. Close it at main-frame commit and drop clicks
from retired menus, since macOS closePopup is posted asynchronously.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 19:59:15 -07:00
committed by GitHub
co-authored by OrcaWin
parent 704d3cdd6f
commit 8b3754d99a
4 changed files with 148 additions and 7 deletions
@@ -36,7 +36,9 @@ function openMainWindowForContextMenu() {
openDevTools: vi.fn(),
closeDevTools: vi.fn(),
replaceMisspelling: vi.fn(),
session: { addWordToSpellCheckerDictionary: vi.fn() }
session: { addWordToSpellCheckerDictionary: vi.fn() },
getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'),
isLoadingMainFrame: vi.fn(() => true)
}
const browserWindowInstance = {
webContents,
@@ -531,7 +533,12 @@ describe('createMainWindow', () => {
expect.objectContaining({ label: 'Table' })
])
)
expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 })
expect(menuPopupMock).toHaveBeenCalledWith({
window: browserWindowInstance,
x: 42,
y: 84,
callback: expect.any(Function)
})
})
it('builds the plain edit menu for the one right-click the renderer marks as plain text', () => {
@@ -567,4 +574,56 @@ describe('createMainWindow', () => {
expect.arrayContaining([expect.objectContaining({ label: 'Format' })])
)
})
it('retires the edit menu with its document but not on same-document navigation', () => {
const { webContents } = openMainWindowForContextMenu()
const emit = (event: string, ...args: unknown[]): void => {
for (const [name, handler] of webContents.on.mock.calls) {
if (name === event) {
handler(...args)
}
}
}
const openMenu = () => {
const closePopup = vi.fn()
buildFromTemplateMock.mockImplementationOnce(() => ({
popup: menuPopupMock,
closePopup
}))
emit(
'context-menu',
{},
{
x: 1,
y: 2,
isEditable: true,
formControlType: 'input',
dictionarySuggestions: [],
misspelledWord: ''
}
)
const template = buildFromTemplateMock.mock.calls.at(-1)?.[0]
const paste = Array.isArray(template)
? template.find((item) => item.label === 'Paste')
: undefined
expect(paste?.click).toBeTypeOf('function')
return { closePopup, paste }
}
const nextDocument = 'file:///opt/orca/renderer/index.html?reload'
const first = openMenu()
emit('did-start-navigation', {}, `${nextDocument}#hash`, true, true)
expect(first.closePopup).not.toHaveBeenCalled()
emit('did-start-navigation', {}, nextDocument, false, true)
expect(first.closePopup).toHaveBeenCalledTimes(1)
// The old document still takes right-clicks until the pending reload commits.
const pending = openMenu()
emit('did-frame-navigate', {}, nextDocument, 200, 'OK', false)
expect(pending.closePopup).not.toHaveBeenCalled()
emit('did-frame-navigate', {}, nextDocument, 200, 'OK', true)
expect(pending.closePopup).toHaveBeenCalledTimes(1)
pending.paste.click()
expect(webContents.send).not.toHaveBeenCalledWith('ui:editableContextPaste', expect.anything())
})
})
@@ -16,9 +16,9 @@ export const attachRouteGuestMock: MainWindowSpy = vi.fn(() => false)
export const retireRouteRendererMock: MainWindowSpy = vi.fn()
export const attachClientPageRendererMock: MainWindowSpy = vi.fn()
export const retireClientPageRendererMock: MainWindowSpy = vi.fn()
export const buildFromTemplateMock: Mock<(...args: unknown[]) => { popup: MainWindowSpy }> = vi.fn(
() => ({ popup: menuPopupMock })
)
export const buildFromTemplateMock: Mock<
(...args: unknown[]) => { popup: MainWindowSpy; closePopup: MainWindowSpy }
> = vi.fn(() => ({ popup: menuPopupMock, closePopup: vi.fn() }))
export const notificationMock: Mock<(...args: unknown[]) => { show: MainWindowSpy }> = vi.fn(
function () {
return { show: notificationShowMock }
@@ -0,0 +1,79 @@
import {
Menu,
type BrowserWindow,
type MenuItemConstructorOptions,
type WebContents
} from 'electron'
type EditableContextMenuPopup = {
open: (template: MenuItemConstructorOptions[], point: { x: number; y: number }) => void
close: () => void
}
type Retirement = { retired: boolean }
type OpenMenu = { menu: Menu; retirement: Retirement }
// macOS closePopup is posted asynchronously, so a click can still land after retirement.
function retirableTemplate(
template: MenuItemConstructorOptions[],
retirement: Retirement
): MenuItemConstructorOptions[] {
return template.map((item) => {
const { click, submenu } = item
return {
...item,
...(click
? {
click: (...args: Parameters<typeof click>) => {
if (!retirement.retired) {
click(...args)
}
}
}
: {}),
...(Array.isArray(submenu) ? { submenu: retirableTemplate(submenu, retirement) } : {})
}
})
}
export function createEditableContextMenuPopup(
window: BrowserWindow,
webContents: Pick<WebContents, 'on'>
): EditableContextMenuPopup {
let current: OpenMenu | null = null
const close = (): void => {
const opened = current
current = null
if (opened) {
opened.retirement.retired = true
opened.menu.closePopup()
}
}
// A menu opened while a reload is pending belongs to the old document; retire it at commit.
webContents.on('did-frame-navigate', (_event, _url, _code, _status, isMainFrame) => {
if (isMainFrame) {
close()
}
})
return {
open: (template, point) => {
close()
const retirement: Retirement = { retired: false }
const menu = Menu.buildFromTemplate(retirableTemplate(template, retirement))
const opened: OpenMenu = { menu, retirement }
current = opened
menu.popup({
window,
x: point.x,
y: point.y,
callback: () => {
if (current === opened) {
current = null
}
}
})
},
close
}
}
@@ -1,4 +1,4 @@
import { ipcMain, Menu, type BrowserWindow } from 'electron'
import { ipcMain, type BrowserWindow } from 'electron'
import { isCrashReportReason } from '../../shared/crash-reporting'
import {
plainTextContextMenuTargetChannel,
@@ -16,6 +16,7 @@ import {
matchingRichMarkdownContextMenuTableTarget,
parseRichMarkdownContextMenuTableTarget
} from './editable-context-menu'
import { createEditableContextMenuPopup } from './editable-context-menu-popup'
import type { CreateMainWindowOptions, MainWindowLoadObserver } from './main-window-contracts'
import { browserRouteWebContentsRegistry } from '../browser/browser-route-session-runtime'
import {
@@ -114,6 +115,7 @@ export function installMainWindowFocusLifecycle(args: {
}
}
ipcMain.on(plainTextContextMenuTargetChannel, onPlainTextContextMenuTarget)
const editableContextMenuPopup = createEditableContextMenuPopup(mainWindow, rendererWebContents)
const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
const tableTarget = matchingRichMarkdownContextMenuTableTarget(
params,
@@ -130,12 +132,13 @@ export function installMainWindowFocusLifecycle(args: {
return
}
// Context menu may precede focus mirroring; trust Electron's editable params.
Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y })
editableContextMenuPopup.open(template, params)
}
rendererWebContents.on('context-menu', onMainContextMenu)
// Dead renderers cannot clear focus mirrors; reset carve-outs so later lifecycles retain app shortcuts.
const resetMarkdownEditorFocus = (): void => {
editableContextMenuPopup.close()
markdownEditorFocused = false
pendingRichMarkdownContextMenuTableTarget = null
pendingPlainTextContextMenuTarget = false