Keep replacement dialogs open when handing off modal history

This commit is contained in:
archipelago
2026-10-05 23:52:43 -04:00
parent 83ba98ab42
commit 29668d3adb
2 changed files with 51 additions and 5 deletions
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import { useModalHistory } from '../useModalHistory'
describe('modal history transitions', () => {
it('replaces a setup dialog with its funding dialog without backing out of it', async () => {
window.history.replaceState({ position: 5 }, '')
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {})
const push = vi.spyOn(window.history, 'pushState')
const first = ref(false), second = ref(false)
const closeFirst = vi.fn(() => { first.value = false })
const closeSecond = vi.fn(() => { second.value = false })
const scope = effectScope()
scope.run(() => { useModalHistory(first, closeFirst); useModalHistory(second, closeSecond) })
first.value = true
await nextTick()
expect(push).toHaveBeenCalledTimes(1)
first.value = false
second.value = true
await nextTick()
await Promise.resolve()
expect(back).not.toHaveBeenCalled()
expect(push).toHaveBeenCalledTimes(1)
expect(window.history.state).toEqual({ position: 5, __archyModal: 1 })
window.history.replaceState({ position: 5 }, '')
window.dispatchEvent(new PopStateEvent('popstate', { state: { position: 5 } }))
await nextTick()
expect(closeSecond).toHaveBeenCalledOnce()
expect(closeFirst).not.toHaveBeenCalled()
expect(back).not.toHaveBeenCalled()
scope.stop()
vi.restoreAllMocks()
})
})
+16 -4
View File
@@ -24,6 +24,9 @@ const stack: Entry[] = []
// gone, so the close-side cleanup must not call history.back() again. // gone, so the close-side cleanup must not call history.back() again.
let poppedClose = false let poppedClose = false
let listening = false let listening = false
// Vue can replace one modal with another in the same update. Reuse that
// history entry rather than scheduling a Back that would close the new modal.
let pendingCleanup: { depth: number } | null = null
function modalDepth(state: unknown): number { function modalDepth(state: unknown): number {
return (state as { __archyModal?: number } | null)?.__archyModal ?? 0 return (state as { __archyModal?: number } | null)?.__archyModal ?? 0
@@ -58,10 +61,11 @@ export function useModalHistory(show: Ref<boolean>, close: () => void) {
try { try {
// Preserve vue-router's own keys in state — clobbering them breaks // Preserve vue-router's own keys in state — clobbering them breaks
// its scroll restoration and position tracking. // its scroll restoration and position tracking.
window.history.pushState( const replacement = pendingCleanup !== null && modalDepth(window.history.state) === pendingCleanup.depth
{ ...(window.history.state ?? {}), __archyModal: stack.length }, pendingCleanup = null
'', const state = { ...(window.history.state ?? {}), __archyModal: stack.length }
) if (replacement) window.history.replaceState(state, '')
else window.history.pushState(state, '')
} catch { /* history can throw in exotic embeds — modal still works */ } } catch { /* history can throw in exotic embeds — modal still works */ }
} else { } else {
const wasTop = stack[stack.length - 1] === entry const wasTop = stack[stack.length - 1] === entry
@@ -75,8 +79,16 @@ export function useModalHistory(show: Ref<boolean>, close: () => void) {
// only if it is still the current one (a route change after opening // only if it is still the current one (a route change after opening
// moves history past it — backing out then would eat a real entry). // moves history past it — backing out then would eat a real entry).
if (wasTop && modalDepth(window.history.state) > stack.length) { if (wasTop && modalDepth(window.history.state) > stack.length) {
const cleanup = { depth: modalDepth(window.history.state) }
pendingCleanup = cleanup
queueMicrotask(() => {
if (pendingCleanup !== cleanup) return
pendingCleanup = null
if (modalDepth(window.history.state) === cleanup.depth && cleanup.depth > stack.length) {
try { window.history.back() } catch { /* same guard as above */ } try { window.history.back() } catch { /* same guard as above */ }
} }
})
}
} }
}) })
} }