fix(player): WaveformPlayer paused itself on play — idempotent claim + hard listener teardown (#384)

Live-debugged in Playwright WebKit with a pause() stack hook: the media
'play' event fired twice (a stale WaveSurfer instance's listeners survive
a destroy() that throws mid-teardown under StrictMode double-mount), so
the second claimPlayback stopped the current owner — this very element.
play → instant self-pause → 'click does nothing'.

- 'play' handler only claims when it doesn't already own the slot
- per-instance stale flag inert-izes leaked handlers
- cleanup detaches handlers (unAll) BEFORE destroy so a throwing destroy
  can't leak them

Verified in WebKit: paused=false, currentTime advancing, 0 stray pause calls.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-12 17:33:04 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent f5579b40aa
commit 77d6477fc9
+19 -2
View File
@@ -106,18 +106,31 @@ export default function WaveformPlayer({
return;
}
wsRef.current = ws;
// Stale-instance guard: a destroy() that throws mid-teardown (observed
// under StrictMode double-mount) can leave this instance's media
// listeners alive — its handlers must become inert, or its duplicate
// 'play' event re-claims the playback slot and stops... ourselves
// (the self-pause bug: waveform drew, click silently un-played).
let stale = false;
ws.on('ready', () => {
if (stale) return;
setDuration(ws.getDuration());
setReady(true);
if (autoPlayRef.current) ws.play().catch(() => {});
});
ws.on('timeupdate', (t) => setCurrentTime(t));
ws.on('timeupdate', (t) => { if (!stale) setCurrentTime(t); });
ws.on('play', () => {
if (stale) return;
setIsPlaying(true);
releaseRef.current = claimPlayback(() => { try { ws.pause(); } catch { /* noop */ } }, source);
// Idempotent: duplicate 'play' events must not re-claim — claiming
// stops the current owner, which would be this very element.
if (!releaseRef.current) {
releaseRef.current = claimPlayback(() => { try { ws.pause(); } catch { /* noop */ } }, source);
}
});
ws.on('pause', () => {
if (stale) return;
setIsPlaying(false);
if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; }
});
@@ -143,7 +156,11 @@ export default function WaveformPlayer({
// (No explicit ws.load — `url` in the create options loads via the media el.)
return () => {
stale = true;
if (releaseRef.current) { releaseRef.current(); releaseRef.current = null; }
// Detach our handlers BEFORE destroy — if destroy throws mid-teardown
// (the swallowed catch below) the listeners must already be gone.
try { ws.unAll(); } catch { /* noop */ }
try { ws.destroy(); } catch { /* already gone */ }
wsRef.current = null;
};