Halmurat T.
· 5 min read

Playwright's onceDialog Has a Silent Handler Leak

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:

src/test/java/pages/AccountListPage.java
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.

What the developer sees
RuntimeException: Unexpected dialog during activation: Type: confirm, Message: Save changes?
Stack trace points to AccountListPage.activate()
What actually happened
1. activate() registered onceDialog handler
2. No dialog fired during tab click — handler stayed registered
3. Test continued to a different page action
4. User clicked Save → app showed "Save changes?" confirm dialog
5. Dangling handler from step 1 intercepted the Save dialog
6. 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.

src/test/java/pages/AccountListPage.java
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:

ScenarioonceDialog (before)onDialog + offDialog (after)
Dialog fires during clickHandles it, auto-removesHandles it, removed in finally
No dialog firesStays registered — eats next dialogRemoved in finally — no leak
Exception during waitHandler leaksfinally 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:

src/test/java/core/ScopedListener.java
// Generic pattern for any scoped Playwright event listener
Consumer<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 fireonceDialog is fine
  • Dialog might or might not fireonDialog + offDialog + finally
  • Global safety net in base classonDialog in setup, scoped to test lifecycle

Related Posts

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.