@samitouri / QOSami-HFS / commits / a082cf2d

fix: prevent nested dialog history overshoot

Massimo Melina committed Apr 26, 2026 at 23:26 UTC a082cf2dc2bbcdd94ee39baa1cdde854a589af5f
1 file changed +17 -9
shared/dialogs.ts
+17 -9
@@ -76,9 +76,8 @@ export function isDescendant(child: Node | null | undefined, parentMatch: Node |
76
77 let waitClosing = Promise.resolve()
78 let waitQueuedCloses = Promise.resolve()
79 -let ignorePopState = false
79 +let ignoredPopStates = 0
80 async function doBack() {
81 - ignorePopState = true
81 const was = history.state
82 return new Promise<void>(async res => {
83 const timeout = Date.now() + 1500
@@ -87,6 +86,9 @@ async function doBack() {
86 const now = Date.now()
87 if (now > timeout) break // emergency brake
88 if (now - lastBack > 1000) { // after this long time we try again
89 + // a queued close may run after another close already reached the route's dialog base entry
90 + if (history.state?.$dialog === undefined || history.state.$dialog === BASE_STATE) break
91 + ignoredPopStates++ // rapid dialog closes can overlap programmatic backs, so each resulting popstate must be ignored independently
92 history.back()
93 lastBack = now
94 }
@@ -110,8 +112,8 @@ const BASE_STATE = 1
112
113 export function Dialogs(props: HTMLAttributes<HTMLDivElement>) {
114 useEffect(() => domOn('popstate', () => {
113 - if (ignorePopState)
114 - return ignorePopState = false
115 + if (ignoredPopStates)
116 + return ignoredPopStates--
117 const d = history.state?.$dialog
118 if (d === undefined) return // not my state, not my business
119 if (d !== BASE_STATE && !dialogs.find(x => x.$id === d)) // it happens if the user, after closing a dialog, goes forward in the history
@@ -250,8 +252,8 @@ export function closeDialog(v?:any, skipHistory=false): Dialog | undefined {
252 // rapid ESC presses can target the next dialog before browser history has caught up with the previous close
253 const closed: Promise<void | undefined> = waitQueuedCloses = waitQueuedCloses
254 .then(() => waitClosing)
253 - // once the history unwind has already been queued, retry the next close without re-checking the same stale state
254 - .then(() => closeDialog(v, true)?.closed)
255 + // after the previous back settles, preserve one history unwind per dialog whenever the entry now matches
256 + .then(() => closeDialog(v, history.state?.$dialog !== dialogs.at(-1)?.$id)?.closed)
257 // return a promise here so mobile callers wait instead of spinning on the still-open dialog stack
258 return { ...d, closed } as Dialog
259 }
@@ -262,15 +264,21 @@ export function closeDialog(v?:any, skipHistory=false): Dialog | undefined {
264 }
265 }
266
267 +let waitHistoryCleanup = Promise.resolve()
268 function closeDialogAt(i: number, value?: any) {
269 const [d] = dialogs.splice(i,1)
270 d.restoreFocus?.focus?.() // if element is not HTMLElement, it doesn't have focus method
271 d.closingValue = value && typeof value === 'object' ? ref(value) : value // since this is being assigned to a valtio proxy, ref is necessary to avoid crashing with unusual (and possibly accidental) objects like React's SynteticEvents
272 d.closed ??= Promise.resolve()
273 d.closed.then(async () => {
271 - // queued ESC closes can empty the stack before the last synthetic history entry has been unwound
272 - while (!dialogs.length && history.state?.$dialog !== undefined && history.state.$dialog !== BASE_STATE)
273 - await doBack()
274 + waitHistoryCleanup = waitHistoryCleanup.then(async () => {
275 + // a matching queued close may already be unwinding the last dialog entry
276 + await waitClosing
277 + // queued ESC closes can empty the stack before the last synthetic history entry has been unwound
278 + while (!dialogs.length && history.state?.$dialog !== undefined && history.state.$dialog !== BASE_STATE)
279 + await back()
280 + })
281 + await waitHistoryCleanup
282 d?.onClose?.(value)
283 })
284 return d