Playwright's onceDialog Has a Silent Handler Leak

Table of Contents
In the original onceDialog post, I recommended onceDialog as the fix for permanent handler leaks from onDialog. That advice holds — when you’re certain the dialog will fire. But there’s a case it doesn’t cover that bit us in production: when the dialog never fires. Then onceDialog doesn’t auto-remove, because it never executed. Your handler sits there, dormant, waiting for the next dialog from a completely different action — and when it fires, it dismisses a dialog your code was counting on.
The Setup
We had a page object for an account management app. One method activated a tab that sometimes triggered an unexpected browser dialog — a server-side validation warning that only appeared under certain data conditions. The team added a onceDialog guard to catch it:
public void activate() { if (!isActive()) { AtomicBoolean dialogDetected = new AtomicBoolean(false); AtomicReference<String> dialogInfo = new AtomicReference<>("");
page.onceDialog(dialog -> { String info = String.format("Type: %s, Message: %s", dialog.type(), dialog.message()); log.error("Unexpected dialog during tab activation: {}", info); dialogDetected.set(true); dialogInfo.set(info); dialog.dismiss(); });
contentFrame().locator(ACCOUNT_LIST_TAB).click();
if (dialogDetected.get()) { throw new RuntimeException( "Unexpected dialog during activation: " + dialogInfo.get()); } waitAccountListPage(); }}This looks correct. If a dialog fires during the click, the handler catches it, dismisses it, and flags the error. If no dialog fires, onceDialog should handle cleanup since it’s a “once” handler. Right?
The Bug: Dangling Handlers Eat Future Dialogs
Here’s what actually happens when no dialog fires during the click: nothing. The onceDialog handler stays registered. It hasn’t fired, so it hasn’t auto-removed. It’s sitting in the page’s event listener queue, waiting.
Three steps later in the same test — or in a completely different test sharing the same page context — someone clicks “Save” and the app shows a legitimate confirmation dialog. The dangling handler intercepts it, dismisses it, and throws a RuntimeException blaming the tab activation. The save never completes. The test fails with an error message pointing to the wrong method.
RuntimeException: Unexpected dialog during activation: Type: confirm, Message: Save changes?
Stack trace points to AccountListPage.activate()1. activate() registered onceDialog handler2. No dialog fired during tab click — handler stayed registered3. Test continued to a different page action4. User clicked Save → app showed "Save changes?" confirm dialog5. Dangling handler from step 1 intercepted the Save dialog6. Handler dismissed it and threw — blaming activate()The developer sees an error about “tab activation” when the real problem is that a Save confirmation got silently dismissed. The data was never saved. The test fails, but the error message is a lie.
The Fix: onDialog + offDialog With finally
The solution is to use onDialog (not onceDialog) with the same handler reference, and remove it explicitly in a finally block. This gives you deterministic cleanup regardless of whether the dialog fired, didn’t fire, or an exception interrupted the flow.
public void activate() { if (!isActive()) { AtomicBoolean dialogDetected = new AtomicBoolean(false); AtomicReference<String> dialogInfo = new AtomicReference<>("");
Consumer<Dialog> guardHandler = dialog -> { String info = String.format("Type: %s, Message: %s", dialog.type(), dialog.message()); log.error("Unexpected dialog during tab activation: {}", info); dialogDetected.set(true); dialogInfo.set(info); dialog.dismiss(); };
page.onDialog(guardHandler); try { contentFrame().locator(ACCOUNT_LIST_TAB).click(); waitAccountListPage(); } finally { page.offDialog(guardHandler); }
if (dialogDetected.get()) { throw new RuntimeException( "Unexpected dialog during activation: " + dialogInfo.get()); } log.info("Account List tab activated successfully"); }}The key difference: onDialog registers your actual handler reference, so offDialog can find and remove it. The finally block guarantees cleanup in all three scenarios:
| Scenario | onceDialog (before) | onDialog + offDialog (after) |
|---|---|---|
| Dialog fires during click | Handles it, auto-removes | Handles it, removed in finally |
| No dialog fires | Stays registered — eats next dialog | Removed in finally — no leak |
| Exception during wait | Handler leaks | finally guarantees cleanup |
The Pattern: Scoped Event Listeners
This isn’t unique to dialog handlers. Any time you register an event listener for a specific action, you need to scope its lifetime to that action. The try/finally pattern works for any Playwright event:
// Generic pattern for any scoped Playwright event listenerConsumer<T> handler = event -> { /* handle */ };page.onSomeEvent(handler);try { performAction();} finally { page.offSomeEvent(handler);}Think of it like resource management. You wouldn’t open a database connection without a finally to close it. Event listeners are the same — they’re resources with a lifecycle, and leaving them open past their intended scope causes the same class of bugs.
When onceDialog Is Still Fine
This isn’t a blanket “never use onceDialog.” It works well when you’re certain the dialog will fire — like dismissing a delete confirmation right before a destructive click. The handler fires, auto-removes, and there’s no dangling risk.
The trap is using onceDialog as a guard — a “just in case” listener for dialogs that might appear. Guards must be cleaned up whether they fire or not, and onceDialog only cleans up when it fires.
Rule of thumb:
- Certain the dialog will fire →
onceDialogis fine - Dialog might or might not fire →
onDialog+offDialog+finally - Global safety net in base class →
onDialogin setup, scoped to test lifecycle
