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:
- React commits the player; the wrapper schedules the preview's connect timer.
- The player unmounts; the wrapper schedules the destroy timer (after the connect timer).
- Connect timer runs β
effect(#updatePlacement) β requestAnimationFrame(...) queued.
- Destroy timer runs β
props = EMPTY_PROPS.
- 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:
npm install
npm run dev, open http://localhost:5173
- 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.
Current Behavior:
Unmounting a
MediaPlayerin the same task in which aSliderPreviewconnects throws an uncaughtTypeErrorfrom arequestAnimationFramecallback β one per slider preview.SliderPreview.onConnectschedules#updatePlacementfrom both aneffect()and aResizeObserver:animationFrameThrottlekeeps the frame id in a closure and returns a bare function, so there is nothing to cancel on dispose:Meanwhile
ComponentInstance.destroy()blanks props and state:this.#slider.disabledis the owning slider's#isDisabled, which readsconst { disabled } = this.$props. Afterdestroy(),$propsisEMPTY_PROPS, sodisabledisundefinedand calling it throws.Because the React wrapper defers both connect and destroy to
setTimeout, they interleave with the queued frame:effect(#updatePlacement)βrequestAnimationFrame(...)queued.props = EMPTY_PROPS.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
animationFrameThrottleacancel()and call it on dispose:Steps To Reproduce:
npm installnpm run dev, open http://localhost:5173window.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:
Anything Else?
No media source and no layout package are required β
SliderPreview.onConnectrunning is the only requirement.DefaultVideoLayouthas 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
requestAnimationFramecallback, so it surfaces onwindow.onerrorrather 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 functionon rapid mount/unmount cycles. Different code path (a pendingplay()promise rather than a queued frame), but the same shape β async work outlivingdestroy()after it has blankedprops/$state. A general "cancel in-flight work on dispose" pass might cover both.