skip to content

A dropdown closes itself with a document-level check `if (!panel.contains(clickedNode)) close()`, and it wrongly closes when the user clicks a control inside the panel that re-renders its contents. Why can panel.contains(clickedNode) be false for a node that was inside the panel, and how do you make the containment check reliable?

level: seniorimportance: should knowfreq 40%

answer

  1. it is a question about now
  2. inclusive: a node contains itself
  3. the clicked node may already be gone
  4. isConnected separates 'outside' from 'nowhere'
  5. decide at pointerdown, not afterwards

basics

~20 s

Node.contains() reports the tree as it is at call time. If the clicked node was removed from the DOM by an earlier re-render, it is now detached, so contains() returns false. Decide containment while the node is still connected, or check isConnected first.

solid answer

~40 s

`Node.contains(other)` is a live question about the current tree: it returns `true` only if `other` is the node itself or one of its descendants **right now**. It is not a record of where the node used to be. When a control inside the panel re-renders and replaces its subtree, the clicked node is detached before your document-level check runs, so `panel.contains(clickedNode)` is `false` and the outside-click branch fires. The fix is to stop asking the question late. Capture the decision while the node is still connected — resolve `clickedNode.closest('[data-dropdown-panel]')` at the moment of the interaction and store the boolean — or, at minimum, treat a detached node as "not outside" by checking `clickedNode.isConnected` before trusting a `false` from `contains`. Deciding on `pointerdown` rather than after the re-render removes the race entirely.

code

javascript · 15 lines
javascript
const panel = document.createElement('div');
const btn = document.createElement('button');
panel.append(btn);
document.body.append(panel);

console.log(panel.contains(panel)); // true  — inclusive
console.log(panel.contains(btn));   // true
console.log(panel.contains(null));  // false — no throw

const mask = panel.compareDocumentPosition(btn);
console.log(Boolean(mask & Node.DOCUMENT_POSITION_CONTAINED_BY)); // true

btn.remove(); // e.g. a re-render replaced the panel's contents
console.log(panel.contains(btn)); // false — same node, different tree
console.log(btn.isConnected);     // false — it is nowhere, not 'outside'

go deeper

for a junior

Know that contains() asks whether one node is inside another right now, that a node counts as containing itself, and that a removed node is inside nothing.

for a middle

Be ready to explain why a deferred containment check can see a detached node, and to name isConnected as the flag that separates 'outside' from 'removed'.

for a senior

Show that you fix this by taking the decision at the earliest reliable moment and storing a boolean, rather than holding a node reference across a re-render, and that you can reach for compareDocumentPosition when you need ordering or a disconnected signal.

for a principal

Own the pattern: one house implementation of dismiss-on-outside-interaction with defined semantics for re-rendered and relocated subtrees, so every widget does not reinvent a racy check.

## What contains() actually asks `Node.contains(other)` returns `true` when `other` is an **inclusive descendant** of the node — that is, when `other` is the node itself or appears somewhere in its subtree. Three properties matter: - It is *inclusive*: `node.contains(node)` is `true`. - It works on any `Node`, not just elements, so text nodes are legitimate arguments. - It returns `false` for `null`, so `panel.contains(null)` does not throw. Crucially, it is evaluated **against the tree as it exists at the moment of the call**. There is no history in it. A node that was a descendant a millisecond ago and has since been removed is simply not a descendant any more. ```js const panel = document.createElement('div'); const btn = document.createElement('button'); panel.append(btn); panel.contains(panel); // true — inclusive panel.contains(btn); // true btn.remove(); panel.contains(btn); // false — btn is now detached btn.isConnected; // false ``` ## Why the dropdown misbehaves The outside-click pattern registers a check at document level and asks, after the fact, whether the clicked node lives inside the panel. That check runs *later* than the interaction it is reasoning about. In between, application code can rebuild part of the panel: a framework re-render, a `replaceChildren`, a list refresh, a toggle that swaps one control for another. The node the user actually clicked is discarded, and your check is now holding a reference to an orphan. `panel.contains(orphan)` correctly answers `false` — the orphan is genuinely not in the panel. The bug is not in `contains`; it is in asking a *positional* question about a node whose position has already changed. The same class of bug appears with any deferred containment check: one inside `setTimeout`, one after an `await`, or one that runs late when something already reacted to the interaction earlier. ## Making the check reliable **Decide early.** Resolve the answer at the earliest point you have the node, and store a primitive, not a node reference: ```js let startedInside = false; document.addEventListener('pointerdown', (e) => { startedInside = Boolean(e.target.closest?.('[data-dropdown-panel]')); }); document.addEventListener('click', () => { if (!startedInside) close(); }); ``` `closest()` is doing the containment test here, but it is doing it while the node is still attached, and the result is a boolean that no later mutation can invalidate. **Recognise the detached case.** If you cannot move the check earlier, do not treat a detached node as "outside": ```js if (!node.isConnected) return; // it left the tree; make no decision if (!panel.contains(node)) close(); ``` `Node.isConnected` is exactly the "is this node in a document tree" flag, and it is the right way to distinguish "outside the panel" from "nowhere at all". **Prefer an ownership marker over a captured reference.** A `data-*` attribute on the panel plus `closest()` survives re-parenting better than a stored element reference, because it re-resolves against whatever tree the node is in. ## When you need order, not containment `contains` answers ancestor/descendant only. When you need to know which of two nodes comes first, or you want containment and order in one call, use `Node.compareDocumentPosition(other)`. It returns a bitmask you test against `Node` constants: - `DOCUMENT_POSITION_DISCONNECTED` (1) — the nodes are not in the same tree - `DOCUMENT_POSITION_PRECEDING` (2) — `other` comes before the node - `DOCUMENT_POSITION_FOLLOWING` (4) — `other` comes after the node - `DOCUMENT_POSITION_CONTAINS` (8) — `other` is an ancestor - `DOCUMENT_POSITION_CONTAINED_BY` (16) — `other` is a descendant The bits combine: a node that contains another reports `CONTAINED_BY | FOLLOWING` (20). The `DISCONNECTED` bit is what makes this method useful for the bug above — it distinguishes "not related" from "related but before/after", which a bare `contains` cannot. Sorting a set of nodes into document order is the other classic use: ```js nodes.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1); ``` ## The takeaway `contains` is cheap, correct, and answers a question about *now*. Bugs come from calling it about a *then*. Either take the measurement while the node is where you think it is, or explicitly handle the case where it has left the tree.

  • How would you distinguish 'the node is outside the panel' from 'the node is no longer in the document at all'?
    Check `node.isConnected` first, or use `panel.compareDocumentPosition(node)` and test the `Node.DOCUMENT_POSITION_DISCONNECTED` bit. A bare `contains()` collapses both cases into `false`, which is exactly why the outside-click check misfires. Treat the disconnected case as 'no information' and skip the decision rather than closing.
  • What does node.contains(node) return, and why does that default matter in practice?
    `true` — `contains` is defined over inclusive descendants. It matters because an interaction on the panel's own border or padding has the panel itself as the target, and an exclusive-descendant check would wrongly classify that as outside. If you ever need the strict version, write `panel.contains(n) && n !== panel`.
  • You need to know which of two elements appears first on the page. Which API gives you that?
    `a.compareDocumentPosition(b)` returns a bitmask; testing it against `Node.DOCUMENT_POSITION_FOLLOWING` tells you `b` comes after `a` in document order, and the containment bits come along in the same result. `contains()` cannot answer ordering at all, and comparing geometry from bounding rectangles is both slower and wrong for wrapped or absolutely-positioned content.

saying these in an interview costs you the question

  • Says contains() remembers where a node used to be
  • Treats a false result as proof the node is outside the panel
  • Thinks node.contains(node) is false
  • Believes contains() throws when given a detached node
  • Reaches for coordinate comparison instead of a tree test

context