Orphaned 3DS2 postMessage listeners in Adyen Web, and a detached-node check that misses shadow DOM

A Drop-in torn down without unmount leaves a window message listener behind, so a later 3DS2 challenge completes twice. The guard uses document.contains, which is always false inside a shadow root.

Category
Code review
Published
Reading time
3 min read

The problem

During a 3-D Secure 2 challenge, the SDK listens on window for a postMessage from the notification page inside the challenge iframe. The listener is removed when the challenge promise settles or when the component unmounts. From the pull request description: when Drop-in is torn down without component.unmount(), for example during single-page-app navigation, componentWillUnmount never runs and the listener stays on window. On the next payment, both the orphaned listener and the new one process the challenge result, which leads to duplicate /paymentDetails calls against one session.

The shared message handler checks the origin, that the data is JSON and the message type. It does not check which transaction the message belongs to, so any live listener accepts any challenge result from the right origin.

The proposed fix

Skip the callbacks when the component's root node is no longer in the document:

DoChallenge3DS2.tsx
private isDetachedFromDOM(): boolean {
    return !this.base || !document.contains(this.base);
}

Both the success and the timeout path return early when it is true.

What I found in review

document.contains does not see into shadow roots

Node.contains walks the light DOM. A node inside a shadow root is not a descendant of document in that sense, so for a Drop-in mounted inside a custom element with a shadow root, document.contains(this.base) is always false. Those integrations would drop every challenge result and the timeout too, with no callback, leaving the shopper on the challenge screen. isConnected gives the same answer for the case in this pull request and stays true inside an attached shadow tree:

TypeScript
private isDetachedFromDOM(): boolean {
    return !this.base?.isConnected;
}

Attached but stale

The guard covers a node that has left the DOM, not a stale instance whose node is still attached, such as a hidden Drop-in or one kept in a cached view. Its listener would still resolve on the next payment's challenge result. I asked whether the notification message carries the threeDSServerTransID, so the handler could reject results for other transactions at the source.

Same pattern, smaller window

DoFingerprint3DS2 registers the same kind of listener for the device fingerprint step, with a 10-second timeout. The window is small, but an orphaned listener can still resolve on a second payment's fingerprint result inside it. A unit test that detaches base and dispatches a message event would cover both components more cheaply than the end-to-end case.

What happened next

Takeaways

  • Listeners on window outlive components that are removed outside the framework's lifecycle. Correlate the message with its transaction, or guarantee cleanup.
  • For "is this element still on the page", isConnected is the shadow-DOM-safe check; document.contains is not.
  • Read the history of a file before proposing the obvious fix. Sometimes it was already tried.

References

Code and history behind this post

More

Theme