fix(checkout): merchant-side escape hatch for Dodo overlay deadlock (#3354)

Dodo's hosted overlay can deadlock: X-button click fires
GET /api/checkout/sessions/{id}/payment-link, the 404 goes unhandled
inside their React, and the resulting Maximum-update-depth render
loop prevents the checkout.closed postMessage from ever escaping the
iframe. Our onEvent handler never runs, the user is stuck.

Add a merchant-side safety net: Escape-key listener on window that
calls DodoPayments.Checkout.close() (works via the merchant-mounted
iframe node, independent of the frozen inner UI), plus an auto-close
from the checkout.error branch so any surfaced error doesn't leave a
zombie overlay behind. Cleanup is wired into destroyCheckoutOverlay.

SDK 1.8.0 has no onCancel/cancel_url/dismissBehavior option —
close() is the only escape hatch Dodo exposes.

Observed 2026-04-23 session cks_0NdL3CalSpBDR6vrMFIS3 from the
?embed=pro-preview iframe-in-iframe landing flow.
This commit is contained in:
Elie Habib
2026-04-23 21:53:01 +04:00
committed by GitHub
parent 26d426369f
commit 7cf0c32eaa

View File

@@ -131,6 +131,28 @@ let initialized = false;
let onSuccessCallback: (() => void) | null = null;
let _resetOverlaySession: (() => void) | null = null;
let _watchersInitialized = false;
let _escapeHandler: ((e: KeyboardEvent) => void) | null = null;
/**
* Dodo's hosted overlay has been observed to deadlock: the in-iframe X
* button hits `GET /api/checkout/sessions/{id}/payment-link` → 404 →
* unhandled rejection in their React code → Maximum-update-depth render
* loop. When that happens, the `checkout.closed` postMessage never
* escapes their iframe, so our onEvent handler can't clean up and the
* user is trapped on the overlay. `DodoPayments.Checkout.close()`
* removes the iframe at the merchant-SDK level and works even when the
* inner overlay is frozen — it's the only safety net available since
* CheckoutOptions has no onCancel/dismissBehavior hook (SDK 1.8.0).
*/
function safeCloseOverlay(): void {
try {
if (DodoPayments.Checkout.isOpen?.()) {
DodoPayments.Checkout.close();
}
} catch {
// Swallow — the overlay is already gone or the SDK is mid-teardown.
}
}
/**
* Initialize the Dodo overlay SDK. Idempotent -- second+ calls are no-ops.
@@ -229,11 +251,23 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
case 'checkout.error':
console.error('[checkout] Overlay error:', event.data?.message);
Sentry.captureMessage(`Dodo checkout overlay error: ${event.data?.message || 'unknown'}`, { level: 'error', tags: { component: 'dodo-checkout' } });
// Release the user if their overlay surfaces an error. The
// deadlock bug (payment-link 404 + render loop) never reaches
// this branch — it traps inside their iframe — but any error
// that DOES escape should not leave a broken overlay mounted.
safeCloseOverlay();
break;
}
},
});
_escapeHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && DodoPayments.Checkout.isOpen?.()) {
safeCloseOverlay();
}
};
window.addEventListener('keydown', _escapeHandler);
initialized = true;
}
@@ -244,6 +278,10 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
export function destroyCheckoutOverlay(): void {
initialized = false;
onSuccessCallback = null;
if (_escapeHandler) {
window.removeEventListener('keydown', _escapeHandler);
_escapeHandler = null;
}
}
function loadPendingCheckoutIntent(): PendingCheckoutIntent | null {