fix(ui): a modal must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m20s
Demo images / Build & push demo images (push) Successful in 3m20s
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the wallet's send/receive modal floating over the destination. The Lightning modal itself did close — the parent did not. Tab views are KeepAlive'd, so navigating deactivates the owner rather than unmounting it, and its Teleported modal keeps rendering. BaseModal now emits close on any route change while shown, fixing the class in one place rather than per button. Every modal here is a transient dialog; none should survive navigation. Two tests pin it, including that a hidden modal stays quiet. Also fixes a test-only regression fromfa26c5fc: useLightningRequired() resolved the Pinia store at composable-call time, so merely having the gate in SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts it bare). The store is now resolved lazily inside the function that needs it — a gate should never be what breaks a component's ability to mount. That one shipped because I verifiedfa26c5fcwith targeted tests and a build but had not re-run the full suite since5718179e. Verified: full suite 103 files / 827 tests green; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c3d5bcd271
commit
204d4523da
@@ -50,7 +50,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
@@ -72,6 +73,23 @@ const emit = defineEmits<{
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// A modal must not outlive the screen that raised it. Tab views are
|
||||
// KeepAlive'd, so navigating away deactivates the owner rather than
|
||||
// unmounting it, and a Teleported modal keeps floating over the destination
|
||||
// (seen with the Lightning "Open a channel" / "Setup Guide" actions, which
|
||||
// route away from inside the wallet's own modal). Closing on any route change
|
||||
// is the general fix — every modal here is a transient dialog.
|
||||
//
|
||||
// `useRoute()` returns undefined when no router is installed (component
|
||||
// tests mount BaseModal bare), so the getter is optional-chained.
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route?.fullPath,
|
||||
(to, from) => {
|
||||
if (to !== from && props.show) emit('close')
|
||||
},
|
||||
)
|
||||
|
||||
const zClass = computed(() => props.zIndex)
|
||||
// The pinned-footer layout needs a height bound or tall content pushes the
|
||||
// footer off-screen anyway. Callers that set their own max-h (e.g. the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import BaseModal from '../BaseModal.vue'
|
||||
|
||||
describe('BaseModal', () => {
|
||||
@@ -21,4 +22,56 @@ describe('BaseModal', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes itself when the route changes', async () => {
|
||||
// Tab views are KeepAlive'd, so navigating away deactivates the owner
|
||||
// rather than unmounting it — a Teleported modal would otherwise keep
|
||||
// floating over the destination screen. Seen with the Lightning modal's
|
||||
// "Open a channel" / "Setup Guide" actions, which route away from inside
|
||||
// the wallet's own send/receive modal.
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/elsewhere', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(BaseModal, {
|
||||
props: { show: true, title: 'Test modal' },
|
||||
slots: { default: '<p>Modal content</p>' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
|
||||
await router.push('/elsewhere')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit close on a route change while hidden', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/elsewhere', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(BaseModal, {
|
||||
props: { show: false, title: 'Test modal' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await router.push('/elsewhere')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,11 +42,13 @@ const status = ref<LightningStatus>('absent')
|
||||
const fundingDirection = ref<'send' | 'receive'>('receive')
|
||||
|
||||
export function useLightningRequired() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
// The store is resolved lazily, inside the functions that need it, rather
|
||||
// than at composable-call time: a component may legitimately be mounted in
|
||||
// a test (or any context) without an active Pinia, and merely *having* this
|
||||
// gate available must not be what breaks it.
|
||||
/** Best status across every known Lightning implementation. */
|
||||
function lightningStatus(): LightningStatus {
|
||||
const pkgs = (appStore.packages ?? {}) as Record<string, { state?: string } | undefined>
|
||||
const pkgs = (useAppStore().packages ?? {}) as Record<string, { state?: string } | undefined>
|
||||
let best: LightningStatus = 'absent'
|
||||
for (const id of LIGHTNING_NODE_APP_IDS) {
|
||||
const entry = pkgs[id]
|
||||
|
||||
Reference in New Issue
Block a user