instrumentDOM leaks a document click handler when the refcount hits zero on a removal with different capture options (e.g. Radix DismissableLayer)
Maintainer thường phản hồi trong vòng 1 ngày
@Lms24 đang làm issue này rồi.
Từ ngày 25/9/2026.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 74/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- javascript, react, typescript
- Lĩnh vực
- frontend
Hướng nghiên cứu
Start in packages/browser-utils/src/instrumentation/dom.ts and run the supplied jsdom reproduction, focusing on the refcount and capture options used by the per-target handler. Done means repeated Radix-style add/remove cycles leave no extra document click handlers while preserving instrumentation coverage; add or update focused tests if the existing suite provides an appropriate entry point.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Is there an existing issue for this?
- I have checked for existing issues
- I have reviewed the documentation
- I am using the latest SDK release: observed on 10.72.0;
instrumentDOMis byte-identical in 10.75.3 and 11.0.0 (build/esm/instrumentation/dom.js)
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/react (the code is in @sentry/browser-utils)
SDK Version
10.72.0 (also checked 10.75.3 and 11.0.0)
Framework Version
React 19.2.8, @radix-ui/react-dismissable-layer 1.1.19 (via Radix Dialog)
Link to Sentry event
N/A. This is a listener leak, not an error event.
Reproduction Example/SDK Setup
// @vitest-environment jsdom (Chrome behaves the same way)
import * as Sentry from "@sentry/react";
// Count what really attaches to document, underneath the SDK's wrapper.
const live = { true: new Set(), false: new Set() };
const P = EventTarget.prototype, add = P.addEventListener, rm = P.removeEventListener;
const cap = (o) => (typeof o === "boolean" ? o : !!o?.capture);
P.addEventListener = function (t, fn, o) { if (this === document && t === "click") live[cap(o)].add(fn); return add.call(this, t, fn, o); };
P.removeEventListener = function (t, fn, o) { if (this === document && t === "click") live[cap(o)].delete(fn); return rm.call(this, t, fn, o); };
const count = () => live.true.size + live.false.size;
Sentry.init({ dsn: "https://[email protected]/0", defaultIntegrations: false, integrations: [Sentry.breadcrumbsIntegration()] });
const baseline = count(); // 1: the global handler
// Radix DismissableLayer's document click listeners: mount, a pointerdown
// inside the layer, then unmount.
const never = () => {}, onCapture = () => {}, onBubble = () => {};
for (let i = 0; i < 20; i++) {
document.addEventListener("click", onCapture, true);
document.addEventListener("click", onBubble);
document.removeEventListener("click", never); // on pointerdown: removes a listener it never added
document.removeEventListener("click", never); // on unmount: same no-op removal
document.removeEventListener("click", onCapture, true);
document.removeEventListener("click", onBubble);
}
console.log(count() - baseline); // 20, expected 0
Steps to Reproduce
- Run the snippet above. Or, in a real app: open and close any Radix
Dialog/Popover/DropdownMenuby clicking inside it (for example a Cancel button), 20 times. - In Chrome, run
getEventListeners(document).click, or use CDPDOMDebugger.getEventListeners.
Expected Result
The SDK's per-target handler is removed when the last click listener on document goes away, so the count stays at the baseline.
Actual Result
Each cycle leaves one more capture-phase click listener on document, and all of them come from the SDK. In our app that was +20 after 20 dialog cycles closed by a click, and 0 when closed with Escape (no pointerdown).
Additional Context
The cause is in instrumentDOM (packages/browser-utils/src/instrumentation/dom.ts):
- The SDK keeps one handler and one
refCountper event type on each target. The handler is registered with theoptionsof whicheveraddEventListenercall first found the count at 0. - It is removed with the
optionsof whicheverremoveEventListenercall brings the count to<= 0. - The count is decremented for every
removeEventListener, even one for a listener that was never added. The browser treats that call as a no-op, but the SDK still counts it.
So when the first add used capture: true and the call that reaches zero is a bubble-phase no-op removal, originalRemoveEventListener(type, handler, undefined) misses the capture-phase handler. The bookkeeping is then deleted anyway, and the next add registers a new handler. DismissableLayer triggers this every time: it calls ownerDocument.removeEventListener("click", handleClickRef.current) on pointerdown and on unmount, even though handleClickRef.current is its initial no-op that was never added.
Because the handler is registered with the caller's full options object, a first caller's { once: true } or signal also applies to the SDK's handler. That can remove the handler while the bookkeeping still says it is attached.
We patched it locally by registering and removing with one normalized capture flag, which brought the growth to 0:
handlerForType.handler = handler;
- originalAddEventListener.call(this, type, handler, options);
+ handlerForType.capture = typeof options === "boolean" ? options : !!options?.capture;
+ originalAddEventListener.call(this, type, handler, handlerForType.capture);
...
- originalRemoveEventListener.call(this, type, handlerForType.handler, options);
+ originalRemoveEventListener.call(this, type, handlerForType.handler, handlerForType.capture);
The refcount can still reach zero early because of the no-op removals. When that happens, the per-target handler is dropped while real listeners remain, so there is a small coverage gap but nothing accumulates. A complete fix would track which (listener, capture) pairs are actually registered. breadcrumbsIntegration({ dom: false }) is not a workaround, because Replay calls addClickKeypressInstrumentationHandler too.
Priority
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it.
- Ngôn ngữ chính
- TypeScript
- Star
- 8.7k
- Fork
- 1.9k
- Merge trung bình
- 1 ngày 15 giờ
- Pull request đã merge (30 ngày)
- 495
Chuẩn bị môi trường
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của getsentry/sentry-javascript
-
Next.js: basePath is concatenated onto absolute router.push hrefs, corrupting navigation transaction namesCó thể đã có người làm @Lms24 đã nhận 4 ngày trước. Đang mởBrowser Bug Next.js Traces
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
getsentry/sentry-javascript#24672 · 2 bình luận · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
javascript
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
getsentry/sentry-javascript#24200 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
javascript Task
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
getsentry/sentry-javascript#24134 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Cloudflare Workers javascript Tests
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
getsentry/sentry-javascript#24051 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Bug Bun javascript
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 92/100
getsentry/sentry-javascript#24045 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của getsentry/sentry-javascript
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
microsoft/vscode-livepreview#876 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needs-triage
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 90/100
JustJarethB/invoicer#54 ·
-
ICP 1.2.0 shows a scheduled task's interval in milliseconds under the label "Interval (In seconds)"Đang mởNeeds Triage Type/Bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
wso2/product-integrator#2585 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Add: Telemundo West sdĐang mởcheck:passed streams:add
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
Maintainer thường phản hồi trong vòng 1 ngày
-
design
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
MTES-MCT/monitor-field#119 ·
Maintainer thường phản hồi trong vòng 1 ngày