Studio: VideoFrameThumbnail enters an infinite error → cleanup loop — one CPU core pinned + unbounded React update allocation (OOM in long-running embeds)

Author: CasbaLCreated Sep 17, 2026Updated Sep 17, 2026

Summary

VideoFrameThumbnail's error handler calls cleanup(), which sets video.src = "" and calls video.load(). Assigning an empty/invalid src itself fires a new error event on the element, which re-enters the same handler — an event-loop-speed infinite loop. Every pass allocates a React update object via setFailed(true) (dispatchSetState).

The same loop also starts on the success path: the seeked handler calls cleanup() after extracting the frame, and the resulting synthetic error re-enters the handler.

Every VideoFrameThumbnail ends up in this loop, whether the video loads or not.

Affected versions

Verified in the shipped studio bundles of 0.8.40 and 0.8.45 (current latest)assets/index-*.js still contains const c=()=>{a.src="",a.load()} with a.addEventListener("error",()=>{s(!0),c()}).

Source: packages/studio/src/components/ui/VideoFrameThumbnail.tsx

Minimal repro

xml
<script>
  const video = document.createElement("video");
  video.addEventListener("error", () => { video.src = ""; video.load(); }); // ← re-enters on its own synthetic error
  video.src = "/missing.mp4";
  video.load();
</script>

Open DevTools → Performance: the error handler spins forever. Instrumenting the src setter counted ~475,000 assignments/second in a real session.

Measured impact (real project, embedded studio)

  • Allocation sampling: ~8.6 MB/s of React update objects ({lane, revertLane, gesture, action, hasEagerState, eagerState, next}) allocated inside dispatchSetState, called from this component's setFailed(true).
  • A heap snapshot after ~40 minutes showed 24.6M update objects / 983 MB (93% of heap) and a renderer pinned at ~100% CPU; in our embedded (Electron) use the JS heap grew to 2.8 GB at ~7 MB/s until the page died.

Suggested fix

Remove the listener before clearing src, and ignore the synthetic error cleanup() itself provokes:

typescript
const onError = () => {
  if (!video.getAttribute("src")) return; // synthetic error from cleanup below
  setFailed(true);
  cleanup();
};

const cleanup = () => {
  video.removeEventListener("error", onError);
  video.src = "";
  video.load();
};

// ... video.addEventListener("error", onError); instead of the inline arrow

Happy to open a PR if helpful.