detached f72eaf6

fix(cef): skip stale JS dialog reset when a newer dialog is pending

The UI reset posted by cancelJSDialogsClosing carries no dialog identity; if a new dialog began before the GTK thread ran it, it would hide that dialog. Re-check pending state under the dialog mutex on the GTK thread and skip the reset when a dialog is pending.
commit
f72eaf648f3ce06bfd01419eeff8e03142200cb0
author
brice · · 2026-10-04 09:49 UTC
parent
b8b745a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/cef-js-dialogs
2 files · +46 -1
M internal/infrastructure/cef/jsdialog_handler.go +20 -1
@@ -105,6 +105,13 @@ func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, jsDia
105105 return call, jsDialogBeginOK
106106 }
107107
108+// hasPending reports whether an unresolved dialog is registered.
109+func (s *jsDialogState) hasPending() bool {
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ return s.current != nil
113+}
114+
108115 func (s *jsDialogState) isDone(call *jsDialogCall) bool {
109116 s.mu.Lock()
110117 defer s.mu.Unlock()
@@ -165,8 +172,20 @@ func (wv *WebView) cancelJSDialogsClosing(closing bool) {
165172 return
166173 }
167174 if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {
168- wv.runOnGTK(cb.OnJSDialogReset)
175+ wv.runOnGTK(func() { wv.resetJSDialogUI(cb.OnJSDialogReset) })
176+ }
177+}
178+
179+// resetJSDialogUI runs on the GTK thread, after a cancel posted it. The reset
180+// carries no dialog identity, so if a newer dialog began in the meantime it is
181+// stale and must be skipped, or it would hide that dialog. A dialog that
182+// begins after this check has its presentation queued behind us on the GTK
183+// thread, so it is never hidden either.
184+func (wv *WebView) resetJSDialogUI(reset func()) {
185+ if wv.jsDialogs.hasPending() {
186+ return
169187 }
188+ reset()
170189 }
171190
172191 func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {
@@ -105,6 +105,13 @@ func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, jsDia
105 return call, jsDialogBeginOK105 return call, jsDialogBeginOK
106 }106 }
107 107
108+// hasPending reports whether an unresolved dialog is registered.
109+func (s *jsDialogState) hasPending() bool {
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ return s.current != nil
113+}
114+
108 func (s *jsDialogState) isDone(call *jsDialogCall) bool {115 func (s *jsDialogState) isDone(call *jsDialogCall) bool {
109 s.mu.Lock()116 s.mu.Lock()
110 defer s.mu.Unlock()117 defer s.mu.Unlock()
@@ -165,8 +172,20 @@ func (wv *WebView) cancelJSDialogsClosing(closing bool) {
165 return172 return
166 }173 }
167 if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {174 if cb := wv.jsDialogUICallbacks(); cb != nil && cb.OnJSDialogReset != nil {
168- wv.runOnGTK(cb.OnJSDialogReset)175+ wv.runOnGTK(func() { wv.resetJSDialogUI(cb.OnJSDialogReset) })
176+ }
177+}
178+
179+// resetJSDialogUI runs on the GTK thread, after a cancel posted it. The reset
180+// carries no dialog identity, so if a newer dialog began in the meantime it is
181+// stale and must be skipped, or it would hide that dialog. A dialog that
182+// begins after this check has its presentation queued behind us on the GTK
183+// thread, so it is never hidden either.
184+func (wv *WebView) resetJSDialogUI(reset func()) {
185+ if wv.jsDialogs.hasPending() {
186+ return
169 }187 }
188+ reset()
170 }189 }
171 190
172 func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {191 func jsDialogTypeFromCEF(t purecef.JsdialogType) (port.JSDialogType, bool) {
M internal/infrastructure/cef/jsdialog_handler_test.go +26 -0
@@ -256,6 +256,32 @@ func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
256256 require.Len(t, cb.calls, 1)
257257 }
258258
259+func TestStaleUIResetSkippedWhenNewerDialogPending(t *testing.T) {
260+ useDirectJSDialogContinue(t)
261+ ui := &jsDialogUIRecorder{handled: true}
262+ wv := newJSDialogWebView(ui)
263+ h := &handlerSet{wv: wv}
264+ old := &stubJSDialogCallback{}
265+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "old", "", old, new(int32))
266+
267+ // Cancel resolves the old dialog; its UI reset is still queued for the
268+ // GTK thread (modeled by calling resetJSDialogUI later).
269+ require.True(t, wv.jsDialogs.cancelPending(false))
270+
271+ // A newer dialog arrives before the queued reset runs.
272+ newer := &stubJSDialogCallback{}
273+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "new", "", newer, new(int32))
274+ require.Len(t, ui.reqs, 2)
275+
276+ wv.resetJSDialogUI(wv.callbacks.OnJSDialogReset)
277+ require.Zero(t, ui.resets, "stale reset must not hide the newer dialog")
278+
279+ // Once nothing is pending the reset goes through.
280+ ui.respond[1](true, "")
281+ wv.resetJSDialogUI(wv.callbacks.OnJSDialogReset)
282+ require.Equal(t, 1, ui.resets)
283+}
284+
259285 func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
260286 useDirectJSDialogContinue(t)
261287 ui := &jsDialogUIRecorder{handled: true}
@@ -256,6 +256,32 @@ func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
256 require.Len(t, cb.calls, 1)256 require.Len(t, cb.calls, 1)
257 }257 }
258 258
259+func TestStaleUIResetSkippedWhenNewerDialogPending(t *testing.T) {
260+ useDirectJSDialogContinue(t)
261+ ui := &jsDialogUIRecorder{handled: true}
262+ wv := newJSDialogWebView(ui)
263+ h := &handlerSet{wv: wv}
264+ old := &stubJSDialogCallback{}
265+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "old", "", old, new(int32))
266+
267+ // Cancel resolves the old dialog; its UI reset is still queued for the
268+ // GTK thread (modeled by calling resetJSDialogUI later).
269+ require.True(t, wv.jsDialogs.cancelPending(false))
270+
271+ // A newer dialog arrives before the queued reset runs.
272+ newer := &stubJSDialogCallback{}
273+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "new", "", newer, new(int32))
274+ require.Len(t, ui.reqs, 2)
275+
276+ wv.resetJSDialogUI(wv.callbacks.OnJSDialogReset)
277+ require.Zero(t, ui.resets, "stale reset must not hide the newer dialog")
278+
279+ // Once nothing is pending the reset goes through.
280+ ui.respond[1](true, "")
281+ wv.resetJSDialogUI(wv.callbacks.OnJSDialogReset)
282+ require.Equal(t, 1, ui.resets)
283+}
284+
259 func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {285 func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
260 useDirectJSDialogContinue(t)286 useDirectJSDialogContinue(t)
261 ui := &jsDialogUIRecorder{handled: true}287 ui := &jsDialogUIRecorder{handled: true}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close