From 29668d3adb141efa217ef28a1f9d428ca769e2b8 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 23:52:43 -0400 Subject: [PATCH] Keep replacement dialogs open when handing off modal history --- .../__tests__/useModalHistory.test.ts | 34 +++++++++++++++++++ neode-ui/src/composables/useModalHistory.ts | 22 +++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 neode-ui/src/composables/__tests__/useModalHistory.test.ts diff --git a/neode-ui/src/composables/__tests__/useModalHistory.test.ts b/neode-ui/src/composables/__tests__/useModalHistory.test.ts new file mode 100644 index 00000000..b1e9b3d7 --- /dev/null +++ b/neode-ui/src/composables/__tests__/useModalHistory.test.ts @@ -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() + }) +}) diff --git a/neode-ui/src/composables/useModalHistory.ts b/neode-ui/src/composables/useModalHistory.ts index df2b6e8d..6ae8e0ed 100644 --- a/neode-ui/src/composables/useModalHistory.ts +++ b/neode-ui/src/composables/useModalHistory.ts @@ -24,6 +24,9 @@ const stack: Entry[] = [] // gone, so the close-side cleanup must not call history.back() again. let poppedClose = 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 { return (state as { __archyModal?: number } | null)?.__archyModal ?? 0 @@ -58,10 +61,11 @@ export function useModalHistory(show: Ref, close: () => void) { try { // Preserve vue-router's own keys in state — clobbering them breaks // its scroll restoration and position tracking. - window.history.pushState( - { ...(window.history.state ?? {}), __archyModal: stack.length }, - '', - ) + const replacement = pendingCleanup !== null && modalDepth(window.history.state) === pendingCleanup.depth + 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 */ } } else { const wasTop = stack[stack.length - 1] === entry @@ -75,7 +79,15 @@ export function useModalHistory(show: Ref, close: () => void) { // 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). if (wasTop && modalDepth(window.history.state) > stack.length) { - try { window.history.back() } catch { /* same guard as above */ } + 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 */ } + } + }) } } })