From e01a599418e8f00f10e540e77a08047a6080f113 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 11 Apr 2026 20:41:19 +0200 Subject: [PATCH] fix(modal): clear stale data-closing flag when re-opened mid-close If the modal is re-enabled within the 150ms close transition the element is still mounted and [open], so the dialogRef watcher does not re-fire. Clear the leftover data-closing flag directly in openDialog() so the dialog doesn't remain stuck at opacity 0. Addresses augmentcode review on #2604. --- frontend/src/components/misc/Modal.test.ts | 32 ++++++++++++++++++++++ frontend/src/components/misc/Modal.vue | 9 +++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/misc/Modal.test.ts b/frontend/src/components/misc/Modal.test.ts index 315c7bb96..b55586fbb 100644 --- a/frontend/src/components/misc/Modal.test.ts +++ b/frontend/src/components/misc/Modal.test.ts @@ -153,4 +153,36 @@ describe('Modal.vue — open race condition (#2590)', () => { wrapper.unmount() }) + + it('clears data-closing when re-opened mid-close transition', async () => { + // Regression guard: if the user toggles enabled back to true while the + // 150ms close transition is still in flight, the is still + // mounted and [open], so the dialogRef watcher does not re-fire. Make + // sure openDialog() clears the leftover data-closing flag itself; + // otherwise the dialog stays stuck at opacity 0. + const wrapper = mount(Modal, { + attachTo: document.body, + props: {enabled: true}, + slots: {default: '

hi

'}, + }) + await flushPromises() + await nextTick() + + const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement + expect(dialog.hasAttribute('open')).toBe(true) + + // Start closing — this sets data-closing and schedules the unmount. + await wrapper.setProps({enabled: false}) + await nextTick() + expect(dialog.dataset.closing).toBe('') + + // Re-open well before the 150ms close timer fires. + await wrapper.setProps({enabled: true}) + await nextTick() + + expect(dialog.dataset.closing).toBeUndefined() + expect(dialog.hasAttribute('open')).toBe(true) + + wrapper.unmount() + }) }) diff --git a/frontend/src/components/misc/Modal.vue b/frontend/src/components/misc/Modal.vue index 451de4256..a875965e9 100644 --- a/frontend/src/components/misc/Modal.vue +++ b/frontend/src/components/misc/Modal.vue @@ -97,7 +97,14 @@ function openDialog() { previouslyFocused.value = document.activeElement showDialog.value = true document.body.style.overflow = 'hidden' - // The actual `showModal()` call happens in the `watch(dialogRef, …)` + // If we're re-opening while the previous close transition is still in + // flight the is still mounted and [open], so the dialogRef + // watcher below won't re-fire. Clear the data-closing flag here so the + // dialog doesn't stay stuck at opacity 0. + if (dialogRef.value) { + delete dialogRef.value.dataset.closing + } + // The initial `showModal()` call happens in the `watch(dialogRef, …)` // below, which fires the moment Vue mounts the . We cannot call // it synchronously here because the element is not in the DOM yet // (v-if="showDialog" only just became true), and we cannot rely on a