Fix BC-10091759496: stop leaking event listeners across Stimulus connect/disconnect#2978
Fix BC-10091759496: stop leaking event listeners across Stimulus connect/disconnect#2978jeremy wants to merge 2 commits into
Conversation
…d visit
Root cause: connect() registered document.addEventListener("keydown",
this.handleKeyDown.bind(this)) and disconnect() called removeEventListener with a
FRESH this.handleKeyDown.bind(this). .bind() returns a new function each call, so
the removal never matched the registered listener — every board visit (Turbo
connect/disconnect) leaked one permanent document keydown listener. Each leaked
listener fires on any Shift-modified keystroke and rebuilds 6 Audio objects, so
typing degrades progressively over a session — worst in long-lived Safari/PWA
sessions that never full-reload.
Fix: store the bound handler once in connect() and remove that same reference in
disconnect(), so the listener is actually cleaned up.
Confirmed in WebKit (Safari engine): current code leaks 1 listener/visit
(1..10 over 10 visits); with this fix the count stays flat at 0.
Card: https://app.basecamp.com/2914079/buckets/27/card_tables/cards/10091759496
There was a problem hiding this comment.
Pull request overview
Fixes a Stimulus lifecycle bug in drag_and_strum_controller where a document-level keydown listener was accumulating across Turbo-driven board navigations, causing progressively worse typing performance (notably in Safari/PWA sessions).
Changes:
- Store a single bound
handleKeyDownfunction reference onconnect(). - Remove the exact same function reference on
disconnect()to prevent leaked listeners.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…_manager Same defect class as the drag_and_strum fix: connect() adds a listener with an inline .bind(this) and disconnect() removes a fresh .bind(this) that never matches. These two bind to this.element (not document), so they don't leak unbounded the way the drag_and_strum document listener did — the listener dies when the element is GC'd. But across a disconnect/reconnect of the same element (cached Turbo previews, morphing) they stack duplicate listeners, so the handler fires N times per event. Store the bound reference once and remove that same reference. Pattern: event-listener-removal-identity-mismatch (bug-reports/patterns).
|
🤖 Broadened to fix the whole defect class in one PR (per our fix-comprehensively practice — the diagnosis found three instances of the same idiom). Second commit applies the identical fix to two more controllers that use the same broken
These bind to Captured as a reusable pattern ( |
Summary
Fixes BC-10091759496 — typing in a card editor progressively freezes Safari (worst in "Add to Dock" PWA mode), getting worse the longer the session runs.
Problem
drag_and_strum_controllerattaches adocument-levelkeydownlistener onconnect(). Under Turbo Drive, every board visit connects the controller again — anddisconnect()never actually removes the previous listener. They accumulate for the life of the tab. Each leaked listener fires on any Shift-modified keystroke (capitals, punctuation while typing) and rebuilds 6Audioobjects, so per-keystroke cost grows with every board you've visited. Long-lived Safari/PWA sessions (which never full-reload) accumulate unbounded listeners → the reported progressive freeze.Solution
Store the bound handler once in
connect()and remove that same reference indisconnect().Root Cause
disconnect()calleddocument.removeEventListener("keydown", this.handleKeyDown.bind(this)).Function.prototype.bind()returns a new function object every call, so the reference passed toremoveEventListenernever equalled the oneaddEventListenerregistered — the removal was a silent no-op.Origin
Introduced in 88ef14c "Preload various instruments" (Andy Smith, 2025-09-26) — the
connect/disconnectpair used a fresh.bind(this)on both sides from the start. Not a regression; born broken.Verification (real Safari engine — no regression test)
Reproduced in WebKit (Playwright's WebKit 26.5 = Safari's engine) by driving the real Stimulus connect/disconnect lifecycle across 10 simulated board visits and counting live
documentkeydownlisteners by function identity:Current code leaks exactly one listener per visit (matching "gets worse the longer the session runs"); with this fix the count stays flat. No unit test added — this is a one-time lifecycle correctness fix with no realistic regression path, and a listener-count test would be permanent scar tissue.
Related / not covered here
new Audio()storm during typing but did not diagnose the listener leak. Typing in comments freezes Safari: global keydown in drag_and_strum + heavy compositing #2926 also flags a separate GPU-compositing cost during editor mutation that this fix does not address — worth a follow-up.handleKeyDownguard (early-return when focus is in a typing context, or only preload during an active drag) would further cut per-fire cost, but is a behavior change and is left out of this focused fix.Bug Card
https://app.basecamp.com/2914079/buckets/27/card_tables/cards/10091759496