From 9a992e3ddd8b70dd6fed0b791f4a24d67ff4e890 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:53:45 +0100 Subject: [PATCH] fix: close a plugin dialog on Escape while the focus is in one of its fields --- .../plugin-host-bridge-origin.test.ts | 28 +++++++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 9 ++++++ lib/plugin-sandbox/protocol.ts | 7 +++++ lib/plugin-sandbox/runtime.tsx | 4 +++ 4 files changed, 48 insertions(+) diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index e2c0ab252..eed032ddb 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -136,6 +136,34 @@ describe('slot sizing', () => { }); }); +describe('Escape inside a slot frame', () => { + const inFrame = (wrapperAttr?: string) => { + const wrapper = document.createElement('div'); + if (wrapperAttr) wrapper.setAttribute(wrapperAttr, 'p1'); + document.body.appendChild(wrapper); + const inst = createSlotInstance({ + plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {}, hostContainer: wrapper, + }); + instances.push(inst); + const keys: string[] = []; + const onKey = (e: KeyboardEvent) => { keys.push(e.key); }; + document.addEventListener('keydown', onKey, true); + post(inst.iframe.contentWindow, 'null', { type: 'frame-escape' }); + document.removeEventListener('keydown', onKey, true); + wrapper.remove(); + return keys; + }; + + it('reaches the host dialog when the frame is one', () => { + expect(inFrame('data-plugin-dialog')).toEqual(['Escape']); + expect(inFrame('data-plugin-dialog-popover')).toEqual(['Escape']); + }); + + it('stays in the frame anywhere else', () => { + expect(inFrame()).toEqual([]); + }); +}); + describe('shouldShow context', () => { it('leaves host callbacks out instead of failing to post them', async () => { const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' })); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 9533ffc37..737e276cb 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -551,6 +551,15 @@ export class SandboxInstance { return; } + case 'frame-escape': { + // Only for a dialog's own frame: elsewhere Escape in a frame means + // nothing to the host, and shouldn't close the message around it. + if (this.iframe.closest('[data-plugin-dialog], [data-plugin-dialog-popover]')) { + this.iframe.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + } + return; + } + case 'slot-resize': { // The iframe has no intrinsic height - sync it to the content height // the sandbox reported, otherwise the wrapper reserves space but the diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 979be4e36..f73b5e662 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -179,6 +179,12 @@ export interface SlotResizeMsg { width?: number; } +/** + * Escape pressed inside a slot frame and left alone by the plugin. Keys in a + * frame never reach the host, so a plugin dialog would not close on it. + */ +export interface FrameEscapeMsg { type: 'frame-escape'; } + export interface SlotShouldShowResultMsg { type: 'slot-should-show-result'; id: string; @@ -235,6 +241,7 @@ export type SandboxToHost = | CallbackInvokeMsg | HookResultMsg | SlotResizeMsg + | FrameEscapeMsg | ThemeAppliedMsg | SlotShouldShowResultMsg | FloatingDropdownOpenMsg diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index cd69acd31..794bf5692 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -1516,6 +1516,10 @@ async function bootSlot(payload: SlotInit): Promise { const reactRoot = renderer.ReactDOM.createRoot(rootEl); reactRoot.render(React.createElement(SlotShell)); + // Last in line: a dropdown in the frame that closes on Escape prevents the default. + window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && event.isTrusted && !event.defaultPrevented) sendToHost({ type: 'frame-escape' }); + }); sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] }); }