From 70d7a78c66d7df1befc35f13dd0ee4d752870bea Mon Sep 17 00:00:00 2001 From: Eve <92513238+1320209572@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:55:38 +0800 Subject: [PATCH] fix(editor): guard danger dialog focus across reopen --- .../components/editor/DangerConfirmDialog.vue | 16 +- .../DangerConfirmDialogFocus.spec.ts | 196 ++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/DangerConfirmDialogFocus.spec.ts diff --git a/apps/desktop/src/components/editor/DangerConfirmDialog.vue b/apps/desktop/src/components/editor/DangerConfirmDialog.vue index d88e954751..b2da011e2e 100644 --- a/apps/desktop/src/components/editor/DangerConfirmDialog.vue +++ b/apps/desktop/src/components/editor/DangerConfirmDialog.vue @@ -24,6 +24,7 @@ const copied = ref(false); // The CodeMirror editor (if any) that was focused when this dialog opened, so // its internal selection can be restored without letting the browser move the caret. let editorRootToRestoreFocus: HTMLElement | null = null; +let focusRestoreGeneration = 0; const props = withDefaults( defineProps<{ @@ -81,6 +82,9 @@ watch( () => open.value, (isOpen) => { if (!isOpen) return; + focusRestoreGeneration += 1; + // Keep the original editor while a prior close animation is still pending. + if (editorRootToRestoreFocus) return; const active = document.activeElement; editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null; }, @@ -97,10 +101,18 @@ watch( */ function onDangerDialogCloseAutoFocus(event: Event) { const target = editorRootToRestoreFocus; - editorRootToRestoreFocus = null; - if (!target || !target.isConnected) return; + if (!target || !target.isConnected) { + editorRootToRestoreFocus = null; + return; + } event.preventDefault(); + // An interrupted close may emit after the dialog has reopened. Suppress the stale native + // restoration, but retain the editor for the next completed close. + if (dialogOpen.value) return; + const generation = focusRestoreGeneration; void import("@codemirror/view").then(({ EditorView }) => { + if (dialogOpen.value || generation !== focusRestoreGeneration || editorRootToRestoreFocus !== target) return; + editorRootToRestoreFocus = null; EditorView.findFromDOM(target)?.focus(); }); } diff --git a/apps/desktop/src/components/editor/__tests__/DangerConfirmDialogFocus.spec.ts b/apps/desktop/src/components/editor/__tests__/DangerConfirmDialogFocus.spec.ts new file mode 100644 index 0000000000..f47ff72091 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/DangerConfirmDialogFocus.spec.ts @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { createApp, defineComponent, h, inject, nextTick, provide, reactive, toRef, type App, type Ref, watch } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; + +vi.mock("@/composables/useSqlHighlighter", () => ({ + useSqlHighlighter: () => ({ highlight: (sql: string) => sql }), +})); + +const dialogHarness = vi.hoisted(() => { + const pendingCloseAutoFocus: Array<() => Event> = []; + return { + pendingCloseAutoFocus, + flushCloseAutoFocus: () => pendingCloseAutoFocus.shift()?.(), + reset: () => pendingCloseAutoFocus.splice(0), + }; +}); + +vi.mock("@/components/ui/dialog", () => { + const dialogContextKey = Symbol("dialog-focus-test"); + type DialogContext = { open: Readonly>; trigger: Element | null }; + + const passthrough = () => + defineComponent({ + setup: + (_, { slots }) => + () => + h("div", slots.default?.()), + }); + + const Dialog = defineComponent({ + props: { open: { type: Boolean, default: false } }, + setup(props, { slots }) { + provide(dialogContextKey, { + open: toRef(props, "open"), + trigger: document.activeElement, + }); + return () => h("div", slots.default?.()); + }, + }); + + const DialogContent = defineComponent({ + emits: ["closeAutoFocus"], + setup(_, { emit, slots }) { + const context = inject(dialogContextKey); + if (!context) throw new Error("DialogContent must be nested in Dialog"); + + watch(context.open, (isOpen, wasOpen) => { + if (isOpen || !wasOpen) return; + dialogHarness.pendingCloseAutoFocus.push(() => { + const event = new Event("close-auto-focus", { cancelable: true }); + emit("closeAutoFocus", event); + if (!event.defaultPrevented && context.trigger instanceof HTMLElement && context.trigger.isConnected) { + context.trigger.focus(); + context.trigger.dispatchEvent(new Event("test-native-focus-restoration")); + } + return event; + }); + }); + + return () => h("div", slots.default?.()); + }, + }); + + return { + Dialog, + DialogContent, + DialogFooter: passthrough(), + DialogHeader: passthrough(), + DialogTitle: passthrough(), + }; +}); + +const mountedApps: App[] = []; +const editorViews: EditorView[] = []; + +afterEach(() => { + dialogHarness.reset(); + for (const app of mountedApps.splice(0)) app.unmount(); + for (const view of editorViews.splice(0)) view.destroy(); + document.body.innerHTML = ""; +}); + +function mountDialog(state: { open: boolean }) { + const dialogHost = document.createElement("div"); + document.body.append(dialogHost); + const app = createApp( + defineComponent({ + setup: () => () => + h(DangerConfirmDialog, { + open: state.open, + sql: "UPDATE users SET active = 0;", + confirmLabel: "Execute", + "onUpdate:open": (open: boolean) => { + state.open = open; + }, + }), + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(dialogHost); + return dialogHost; +} + +function createEditor() { + const editorHost = document.createElement("div"); + document.body.append(editorHost); + const view = new EditorView({ + state: EditorState.create({ + doc: "SELECT 1;\nUPDATE users SET active = 0;\nSELECT 2;", + selection: { anchor: 20 }, + }), + parent: editorHost, + }); + editorViews.push(view); + view.focus(); + view.contentDOM.addEventListener("test-native-focus-restoration", () => { + view.dispatch({ selection: { anchor: 0 } }); + }); + return view; +} + +function findConfirmButton(host: HTMLElement) { + const button = [...host.querySelectorAll("button")].find((candidate) => candidate.textContent?.includes("Execute")); + if (!button) throw new Error("Confirm button not found"); + return button; +} + +describe("DangerConfirmDialog focus restoration", () => { + it("prevents native restoration and keeps the CodeMirror selection after confirm", async () => { + const view = createEditor(); + const state = reactive({ open: true }); + const dialogHost = mountDialog(state); + await nextTick(); + + const confirm = findConfirmButton(dialogHost); + confirm.focus(); + confirm.click(); + await nextTick(); + + const closeAutoFocus = dialogHarness.flushCloseAutoFocus(); + expect(closeAutoFocus?.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(document.activeElement).toBe(view.contentDOM)); + expect(view.state.selection.main.head).toBe(20); + }); + + it("leaves default restoration available for non-CodeMirror triggers", async () => { + const trigger = document.createElement("button"); + document.body.append(trigger); + trigger.focus(); + + const state = reactive({ open: true }); + const dialogHost = mountDialog(state); + await nextTick(); + const confirm = findConfirmButton(dialogHost); + confirm.focus(); + confirm.click(); + await nextTick(); + + const closeAutoFocus = dialogHarness.flushCloseAutoFocus(); + expect(closeAutoFocus?.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(trigger); + }); + + it("preserves the pending editor across an interrupted close and reopen", async () => { + const view = createEditor(); + const state = reactive({ open: true }); + const dialogHost = mountDialog(state); + await nextTick(); + const confirm = findConfirmButton(dialogHost); + + confirm.focus(); + confirm.click(); + await nextTick(); + state.open = true; + await nextTick(); + confirm.focus(); + + const interruptedClose = dialogHarness.flushCloseAutoFocus(); + expect(interruptedClose?.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(confirm); + expect(view.state.selection.main.head).toBe(20); + + confirm.click(); + await nextTick(); + const finalClose = dialogHarness.flushCloseAutoFocus(); + expect(finalClose?.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(document.activeElement).toBe(view.contentDOM)); + expect(view.state.selection.main.head).toBe(20); + }); +});