fix(player): guard play/pause/removeEventListener against null refs#1016
fix(player): guard play/pause/removeEventListener against null refs#1016miguel-heygen wants to merge 2 commits into
Conversation
…refs The player runtime calls .play(), .pause(), and .removeEventListener() on DOM elements and iframe window/document references that can become stale or null during composition reloads, iframe navigation, and element teardown. This caused 100+ unhandled errors per month: - "e.play is not a function" (15 crashes/month) - "e.pause is not a function" (91 errors/month) - "A.current.removeEventListener is not a function" (17 crashes/month) - "P.removeEventListener is not a function" (10 crashes/month) Guard all call sites with typeof checks before invoking: - core/runtime/media.ts: el.play() and el.pause() in syncRuntimeMedia - player/parent-media.ts: proxy entry play/pause in playAll/pauseAll/destroy - studio/player/usePlaybackKeyboard.ts: iframeWin/iframeDoc removeEventListener - studio/player/PlayerControls.tsx: target.removeEventListener in scrub cleanup - studio/player/Player.tsx: iframe/player removeEventListener in mount cleanup - core/runtime/init.ts: node and mediaEl removeEventListener in cleanup handlers
jrusso1020
left a comment
There was a problem hiding this comment.
Verdict: COMMENT (would-be APPROVE)
Defensive PR with appropriate scope. Holding for James's stamp greenlight.
Sanity checks
-
Scope is correctly bounded — element refs only, globals untouched. Cross-checked every
removeEventListenercall site acrosspackages/core/src/runtime/,packages/player/src/, andpackages/studio/src/player/. The PR guards exactly the sites that operate on element refs (node,mediaEl,iframe,player,target,iframeWin,iframeDoc) — every one of which can become stale if the DOM element is removed/replaced before cleanup runs. It deliberately doesn't guardwindow.removeEventListener/document.removeEventListenercalls (e.g.picker.ts:248-250,init.ts:2109-2121,controls.ts:366-374,useTimelineClipDrag.ts:372-374,EditModal.tsx:43,53), which is right —windowanddocumentcan't be destroyed during a session. ✓ Correct parity logic. -
All play()/pause() call sites on DOM media accounted for.
media.ts:261/277/286— guarded.parent-media.ts:101/121/124/128/283/297— guarded. The remaining.play()/.pause()sites inruntime/player.ts,runtime/adapters/{animejs,gsap}.tsoperate on JS timeline objects (GSAP/AnimeJS instances), not DOM media — different invariants, not in scope. ✓ -
Tests pass at scale: 983 (core) + 110 (player) + 606 (studio) = 1,699 tests. CI mid-flight with 11 success / 17 in_progress / 0 failures so far.
-
Shape of each guard matches the loop semantics:
playAll():continueearly (skips the element entirely — right, since src+play are both prerequisites)pauseAll(),destroy(),_adoptIframeMedia,_detachIframeMedia: no-op the pause but continue with other work like clearing src (right, pause is best-effort)syncRuntimeMediael.play !== "function":continueto skip the whole element (right — if play is missing, the element is in an invalid state)- All cleanup-style guards (
Player.tsx,PlayerControls.tsx,usePlaybackKeyboard.ts,init.ts): no-op the removeEventListener, continue with surrounding cleanup (right — failed unbind doesn't gate other cleanup)
One observation worth surfacing — observability gap
This is a symptom fix, not a root cause fix. The 133 errors/month come from somewhere — most likely stale refs in tracked collections (metadataBoundMedia Set, this._entries array, React-captured iframe/player refs) that are not being cleaned up when their underlying DOM elements are removed. The fix correctly hardens the consumer side, but loses the signal that something is wrong upstream:
- Before: 133 errors/month tell us "something is putting destroyed elements into our tracked collections." Noisy but informative.
- After: 0 errors/month. Quiet but blind — we no longer see when the data-structure invariants break.
Optional defensive-with-observability variant: emit a low-volume telemetry event (media_ref_destroyed or similar) the first time per session a guard trips, so we keep a signal channel for the root cause without re-flooding the channel:
if (typeof el.play !== "function") {
if (!sessionFlags.has("guard:media_play")) {
sessionFlags.add("guard:media_play");
trackStudioEvent("guard_triggered", { site: "syncRuntimeMedia:play" });
}
continue;
}Not blocking — the current PR is a valid first step to clean up the telemetry. But worth a follow-up either to find the root causes (e.g., proper unbind paths from metadataBoundMedia when nodes detach, MutationObserver / WeakRef-based collection hygiene) or to add the gated telemetry above.
— Rames Jusso
Address review feedback on PR #1016: the typeof guards are a symptom fix — the real issue is stale refs accumulating in tracked collections. Root-cause fix: prune disconnected elements from metadataBoundMedia during each rebind cycle in init.ts so removed DOM nodes don't linger. Observability: emit a once-per-session `guard_triggered` event via CustomEvent → trackStudioEvent when a typeof guard trips in ParentMediaManager, so we keep a low-volume signal for upstream data-structure invariant violations without re-flooding telemetry.
Summary
.play(),.pause(), and.removeEventListener()calls across the player runtime, parent-media proxy, and studio player hooks against null, stale, or non-media element refse.play is not a function(15/mo),e.pause is not a function(91/mo),removeEventListener is not a function(27/mo)typeof x.method === "function"checks so they silently skip destroyed or non-conformant elements rather than throwingChanged files
packages/core/src/runtime/media.tsel.play()andel.pause()insyncRuntimeMediaactive/inactive pathspackages/core/src/runtime/init.tsnode.removeEventListenerin cleanup handler,mediaEl.removeEventListenerin metadata unbindpackages/player/src/parent-media.tsm.el.play/pauseinplayAll,pauseAll,destroy,_detachIframeMedia,_adoptIframeMediapackages/studio/src/player/hooks/usePlaybackKeyboard.tsiframeWin/iframeDoc.removeEventListenerin shortcut cleanup closurepackages/studio/src/player/components/PlayerControls.tsxtarget.removeEventListenerin scrub pointer cleanuppackages/studio/src/player/components/Player.tsxiframe/player.removeEventListenerin mount-effect cleanupTest plan
bun run buildpasses (all packages)bunx oxlintclean on all 6 filesbunx oxfmt --checkclean on all 6 filespackages/coretests pass (983 tests)packages/playertests pass (110 tests)packages/studiotests pass (606 tests)Closes PRINFRA-174