Skip to content

πŸ› Bug: SliderPreview's animationFrameThrottle frame outlives destroy() β€” "disabled is not a function" on unmountΒ #1851

Description

@naghtigal

Current Behavior:

Unmounting a MediaPlayer in the same task in which a SliderPreview connects throws an uncaught TypeError from a requestAnimationFrame callback β€” one per slider preview.

TypeError: disabled is not a function
    at #isDisabled                        (@vidstack_react.js:8515)
    at SliderPreviewInstance.<anonymous>   (@vidstack_react.js:8045)
    at chunk-Z5L6KLLP.js:604               (animationFrameThrottle's rAF callback)

SliderPreview.onConnect schedules #updatePlacement from both an effect() and a ResizeObserver:

#updatePlacement = animationFrameThrottle(() => {
  const { disabled, orientation } = this.#slider;
  if (disabled()) return;                  // <- throws here
  ...
});

animationFrameThrottle keeps the frame id in a closure and returns a bare function, so there is nothing to cancel on dispose:

function animationFrameThrottle(func) {
  let id = -1, lastArgs;
  function throttle(...args) {
    lastArgs = args;
    if (id >= 0) return;
    id = window.requestAnimationFrame(() => {
      func.apply(this, lastArgs);          // still runs after destroy()
      id = -1;
      lastArgs = void 0;
    });
  }
  return throttle;                         // no .cancel()
}

Meanwhile ComponentInstance.destroy() blanks props and state:

this.props  = EMPTY_PROPS;
this.$state = null;

this.#slider.disabled is the owning slider's #isDisabled, which reads const { disabled } = this.$props. After destroy(), $props is EMPTY_PROPS, so disabled is undefined and calling it throws.

Because the React wrapper defers both connect and destroy to setTimeout, they interleave with the queued frame:

  1. React commits the player; the wrapper schedules the preview's connect timer.
  2. The player unmounts; the wrapper schedules the destroy timer (after the connect timer).
  3. Connect timer runs β†’ effect(#updatePlacement) β†’ requestAnimationFrame(...) queued.
  4. Destroy timer runs β†’ props = EMPTY_PROPS.
  5. The queued frame fires β†’ disabled() β†’ TypeError.

Expected Behavior:

Tearing a player down should not throw. A pending placement frame belonging to a destroyed component should be cancelled, or should no-op.

Suggested fix β€” give animationFrameThrottle a cancel() and call it on dispose:

throttle.cancel = () => {
  if (id >= 0) window.cancelAnimationFrame(id);
  id = -1;
  lastArgs = void 0;
};
// SliderPreview.onConnect
onDispose(() => this.#updatePlacement.cancel());

Steps To Reproduce:

  1. npm install
  2. npm run dev, open http://localhost:5173
  3. The page mounts a player and unmounts it as soon as the slider previews connect. It reports REPRODUCED and prints the errors caught on window.onerror.

There is also a headless check: npx playwright install chromium && npm run verify (exits 1 when the crash reproduces).

Rate: 8–10 out of 10 page loads in dev, 19/20 against vite build + vite preview. It is a frame race, so an occasional load survives. Only the first player mount in a page's lifetime races β€” re-running in-page always passes β€” so the repro re-runs via a full reload.

Reproduction Link: https://github.com/naghtigal/vidstack-slider-preview-repro

I used a plain Vite repo rather than StackBlitz deliberately: this is a timing race between a queued animation frame and a deferred destroy, and I did not want a WebContainer iframe's scheduling to mask it. Happy to port it to StackBlitz if you'd prefer.

Environment:

  • Framework: React 19.2
  • @vidstack/react: 1.15.6
  • Meta Framework: none (Vite 7)
  • Node: 24
  • Device: MacBook (arm64)
  • OS: macOS 15
  • Browser: Chrome 150 (also reproduces on Playwright's bundled Chromium)

Anything Else?

No media source and no layout package are required β€” SliderPreview.onConnect running is the only requirement. DefaultVideoLayout has two slider previews (time + volume), so a teardown there produces exactly two of these errors, which is what the repro mirrors.

The throw happens inside a requestAnimationFrame callback, so it surfaces on window.onerror rather than in a React error boundary. It is non-fatal β€” React keeps rendering β€” but it is uncaught, so it lands in error reporting.

Possibly related: #1753 reports this.$state[prop2] is not a function on rapid mount/unmount cycles. Different code path (a pending play() promise rather than a queued frame), but the same shape β€” async work outliving destroy() after it has blanked props/$state. A general "cancel in-flight work on dispose" pass might cover both.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions