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