Skip to content

Night Shift: fix event listener leak in audio hook#16

Open
EtanHey wants to merge 1 commit intomasterfrom
nightshift/2026-02-19-1094
Open

Night Shift: fix event listener leak in audio hook#16
EtanHey wants to merge 1 commit intomasterfrom
nightshift/2026-02-19-1094

Conversation

@EtanHey
Copy link
Owner

@EtanHey EtanHey commented Feb 19, 2026

User description

Automated improvement by GolemsZikaron Night Shift.

fix event listener leak in audio hook


PR Type

Bug fix


Description

  • Fix event listener memory leak in useAudioPreloader hook

  • Track ended handlers in Map for proper cleanup

  • Replace ineffective anonymous function removal with actual handler reference

  • Prevent retained closures from accumulating in memory


File Walkthrough

Relevant files
Bug fix
useAudioPreloader.ts
Track and properly remove audio ended event listeners       

src/hooks/useAudioPreloader.ts

  • Added endedHandlers Map to track audio ended event handlers by line
    number
  • Store handler reference when adding 'ended' event listener
  • Retrieve and remove actual handler reference during cleanup instead of
    anonymous function
  • Clear endedHandlers Map during effect cleanup
+7/-2     

Note

Low Risk
Small, localized change to hook cleanup logic; behavior should be unchanged aside from correctly detaching event listeners.

Overview
Fixes an event-listener leak in useAudioPreloader by tracking per-snippet ended handlers and removing the exact handler functions during effect cleanup.

Cleanup now iterates the audio map with lineNumber, detaches the stored ended listener, clears the handler map, and then clears audio references.

Written by Cursor Bugbot for commit 3fc8807. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • Bug Fixes
    • Improved audio event listener management to ensure proper cleanup and prevent potential resource leaks during audio playback.

The cleanup function used `removeEventListener('ended', () => {})` which
is a no-op — the anonymous function doesn't match the original handler
reference. Track ended handlers in a Map so they can be properly removed
during effect cleanup, preventing memory leaks from retained closures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Feb 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
songscript Ready Ready Preview, Comment Feb 19, 2026 2:04am

@qodo-code-review
Copy link

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
No custom compliance provided

Follow the guide to enable custom compliance check.

Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

Improves event listener cleanup in the audio preloader hook by introducing an endedHandlers map to track per-line ended event handlers, ensuring proper removal during component cleanup instead of using placeholder handlers.

Changes

Cohort / File(s) Summary
Audio Preloader Hook
src/hooks/useAudioPreloader.ts
Adds endedHandlers map to track ended event handlers per audio line number. Stores handler references during listener setup and removes them with the correct mapped handler during cleanup, then clears the map.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 Handlers tracked with care so fine,
Each audio line gets its own sign,
Cleanup whispers, no more lost tracks,
Every listener safely comes back! 🎵

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Night Shift: fix event listener leak in audio hook' accurately describes the main change—fixing a memory leak by properly tracking and removing event listeners in the useAudioPreloader hook.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch nightshift/2026-02-19-1094

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Add missing dependency to useEffect

Add the onEnded prop to the useEffect dependency array to prevent a stale
closure bug where an old version of the function might be called.

src/hooks/useAudioPreloader.ts [114]

     ... (clipped)
        currentlyPlayingRef.current = null
      }
-   }, [snippets])
+   }, [snippets, onEnded])
  
    const play = useCallback((lineNumber: number) => {
     ... (clipped)

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a missing dependency (onEnded) in the useEffect hook, which can lead to a stale closure bug, a common and important issue to fix in React.

Medium
General
Use WeakMap for handler storage

Use a WeakMap with the audio element as the key to store event handlers, instead
of a Map with lineNumber as the key.

src/hooks/useAudioPreloader.ts [52-107]

-const endedHandlers = new Map<number, () => void>()
+const endedHandlers = new WeakMap<HTMLAudioElement, () => void>()
 ...
-endedHandlers.set(lineNumber, handleEnded)
+endedHandlers.set(audio, handleEnded)
 ...
-audioMapRef.current.forEach((audio, lineNumber) => {
-  const handler = endedHandlers.get(lineNumber)
+audioMapRef.current.forEach((audio) => {
+  const handler = endedHandlers.get(audio)
   if (handler) audio.removeEventListener('ended', handler)
 })

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 3

__

Why: This is a reasonable alternative implementation, but the current approach using a Map with lineNumber as a key is also correct and doesn't have the "key collision" issue in this context. The benefit is marginal.

Low
  • More

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/hooks/useAudioPreloader.ts (2)

86-91: ⚠️ Potential issue | 🟠 Major

onEnded stale closure: wrong callback silently invoked after prop change.

handleEnded captures onEnded from the render-time closure (line 88), but onEnded is absent from the useEffect dependency array (line 114). If the parent re-renders with a new onEnded while snippets stays referentially identical, every ended event will invoke the old callback—silently, with no error.

Adding onEnded to the dep array is not the right fix either; it would tear down and recreate all Audio objects on every callback identity change.

The idiomatic React 19.2 fix is useEffectEvent, which is imported directly from react. Effect Events always "see" the latest values from render (like props and state) without re-synchronizing the Effect, so they are excluded from Effect dependencies.

🛡️ Proposed fix using useEffectEvent
-import { useEffect, useState, useRef, useCallback } from 'react'
+import { useEffect, useState, useRef, useCallback, useEffectEvent } from 'react'

Then, inside the hook body (before the useEffect), add:

+  const handleEndedEvent = useEffectEvent((lineNumber: number) => {
+    setIsPlaying(false)
+    onEnded?.(lineNumber)
+  })

And simplify handleEnded inside the snippets.forEach callback:

-      const handleEnded = () => {
-        setIsPlaying(false)
-        if (onEnded) {
-          onEnded(lineNumber)
-        }
-      }
+      const handleEnded = () => handleEndedEvent(lineNumber)

handleEndedEvent always reads the current onEnded at call time, so the dep array stays [snippets].

Also applies to: 114-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudioPreloader.ts` around lines 86 - 91, The handleEnded
function inside useAudioPreloader captures a stale onEnded prop because it's not
updated without tearing down Audio objects; replace the closure with an Effect
Event: import useEffectEvent from react and create a handler like
handleEndedEvent = useEffectEvent((ln) => { setIsPlaying(false); if (onEnded)
onEnded(ln); }); Then, inside the snippets.forEach where you currently assign
handleEnded to audio.onended, use handleEndedEvent(lineNumber) (or pass the
event wrapper) so the audio event always calls the latest onEnded while keeping
the useEffect dependency array as [snippets].

1-1: ⚠️ Potential issue | 🟠 Major

Add test file for useAudioPreloader.ts.

The coding guidelines require all helpers and utilities in **/*.ts to have corresponding test files. This hook implements non-trivial logic (Map-based handler tracking, cleanup ordering, load-state gating) but has no test file, while other hooks in the same directory (e.g., useAnonymousProgress.ts, useProgress.ts) all have tests following the naming convention.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudioPreloader.ts` at line 1, Add a test file for the hook
useAudioPreloader.ts (e.g., useAudioPreloader.test.ts) that verifies its main
behaviors: Map-based handler tracking, cleanup/removal ordering, and load-state
gating. Use a hook test utility (renderHook from `@testing-library/react-hooks` or
React Testing Library) and mock HTMLAudioElement events to simulate load and
error; assert that registered handlers stored in the Map are invoked on load,
removed on cleanup, and that subsequent calls respect the loaded gate so
handlers aren’t re-invoked unnecessarily; also test that removing a specific
handler does not affect others. Reference the hook by name useAudioPreloader in
your tests and follow the existing test patterns used for
useAnonymousProgress.ts and useProgress.ts.
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9a964b0 and 3fc8807.

📒 Files selected for processing (1)
  • src/hooks/useAudioPreloader.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TanStack Start framework with Bun runtime for the application

Tests must pass locally via bun run test before committing code, as Husky pre-commit hooks will block commits with failing tests

Files:

  • src/hooks/useAudioPreloader.ts
**/*.ts

📄 CodeRabbit inference engine (CLAUDE.md)

All new helpers and utilities MUST have corresponding test files

Files:

  • src/hooks/useAudioPreloader.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (1)
src/hooks/useAudioPreloader.ts (1)

52-52: The core fix is correct — LGTM.

Storing each handleEnded reference in endedHandlers and retrieving it by lineNumber during cleanup guarantees removeEventListener receives the identical function object, which is the only way the browser will actually deregister the listener. The endedHandlers.clear() on line 110 is a tidy final step.

Also applies to: 97-97, 104-111

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/useAudioPreloader.ts`:
- Line 50: The cleanup misses audio elements that never fired
canplay/canplaythrough because only successfully-ready audios are kept in
audioMapRef; to fix, in useAudioPreloader track every created HTMLAudioElement
and its per-audio handlers (handleCanPlay, handleError and endedHandlers) in a
local map alongside audioMapRef, then in the effect cleanup iterate that full
map to removeEventListener for those handlers, pause and set audio.src = '' (and
optionally load()), and clear both the created-audio map and audioMapRef; ensure
you reference the existing symbols audioMapRef, handleCanPlay, handleError,
endedHandlers and perform listener removal for those same handler functions so
no callbacks fire after unmount.

---

Outside diff comments:
In `@src/hooks/useAudioPreloader.ts`:
- Around line 86-91: The handleEnded function inside useAudioPreloader captures
a stale onEnded prop because it's not updated without tearing down Audio
objects; replace the closure with an Effect Event: import useEffectEvent from
react and create a handler like handleEndedEvent = useEffectEvent((ln) => {
setIsPlaying(false); if (onEnded) onEnded(ln); }); Then, inside the
snippets.forEach where you currently assign handleEnded to audio.onended, use
handleEndedEvent(lineNumber) (or pass the event wrapper) so the audio event
always calls the latest onEnded while keeping the useEffect dependency array as
[snippets].
- Line 1: Add a test file for the hook useAudioPreloader.ts (e.g.,
useAudioPreloader.test.ts) that verifies its main behaviors: Map-based handler
tracking, cleanup/removal ordering, and load-state gating. Use a hook test
utility (renderHook from `@testing-library/react-hooks` or React Testing Library)
and mock HTMLAudioElement events to simulate load and error; assert that
registered handlers stored in the Map are invoked on load, removed on cleanup,
and that subsequent calls respect the loaded gate so handlers aren’t re-invoked
unnecessarily; also test that removing a specific handler does not affect
others. Reference the hook by name useAudioPreloader in your tests and follow
the existing test patterns used for useAnonymousProgress.ts and useProgress.ts.

@@ -49,6 +49,8 @@ export function useAudioPreloader(
setState({ loaded: 0, total, ready: false })
audioMapRef.current.clear()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unloaded audio elements not fully cleaned up on early unmount.

audioMapRef.current only contains elements that successfully fired canplaythrough/canplay (line 65). If the component unmounts while some elements are still loading, the cleanup loop at line 104 never touches them — their canplaythrough/canplay/error one-shot listeners remain live and will fire post-cleanup, calling setState (line 66–70) and writing into an already-cleared audioMapRef. Their audio.src is also never set to '', so the browser continues the network fetch.

This is pre-existing, but a straightforward fix is to track all created audio objects in the same effect scope:

🔧 Proposed fix
     audioMapRef.current.clear()
+    const allAudio: Array<{ audio: HTMLAudioElement; lineNumber: number }> = []
     const endedHandlers = new Map<number, () => void>()

     snippets.forEach(({ lineNumber, audioUrl }) => {
       if (!audioUrl) return

       const audio = new Audio()
+      allAudio.push({ audio, lineNumber })
       // ... rest of forEach unchanged
     })

     return () => {
-      audioMapRef.current.forEach((audio, lineNumber) => {
+      allAudio.forEach(({ audio, lineNumber }) => {
         audio.pause()
         const handler = endedHandlers.get(lineNumber)
         if (handler) audio.removeEventListener('ended', handler)
+        audio.removeEventListener('canplaythrough', /* handleCanPlay ref needed — see below */)
+        audio.removeEventListener('canplay', /* handleCanPlay ref needed */)
+        audio.removeEventListener('error', /* handleError ref needed */)
         audio.src = ''
       })
       endedHandlers.clear()
       audioMapRef.current.clear()
       currentlyPlayingRef.current = null
     }

handleCanPlay and handleError are already declared with const inside the forEach; storing them in similar per-lineNumber maps (alongside endedHandlers) allows the cleanup to remove them too.

Also applies to: 102-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudioPreloader.ts` at line 50, The cleanup misses audio elements
that never fired canplay/canplaythrough because only successfully-ready audios
are kept in audioMapRef; to fix, in useAudioPreloader track every created
HTMLAudioElement and its per-audio handlers (handleCanPlay, handleError and
endedHandlers) in a local map alongside audioMapRef, then in the effect cleanup
iterate that full map to removeEventListener for those handlers, pause and set
audio.src = '' (and optionally load()), and clear both the created-audio map and
audioMapRef; ensure you reference the existing symbols audioMapRef,
handleCanPlay, handleError, endedHandlers and perform listener removal for those
same handler functions so no callbacks fire after unmount.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant