Current Behavior:
Calling player.play() from inside an auto-play-fail listener (the usual "retry muted" fallback) recurses synchronously until RangeError: Maximum call stack size exceeded when the visitor has prefers-reduced-motion: reduce.
The cycle:
-
#attemptAutoplay sets autoPlaying = true and calls play().
-
MediaRequestManager.play throws synchronously in throwIfAutoplayingWithReducedMotion(isAutoPlaying) — before the first await — so the catch handles play-fail synchronously (this.#stateMgr.handle(errorEvent)).
-
MediaStateManager['play-fail'] dispatches auto-play-fail before it runs autoPlaying.set(false):
if (event.autoPlay) {
this.handle(this.createEvent('auto-play-fail', { ... }));
autoPlaying.set(false); // too late: listeners already ran
}
-
The listener calls player.play(). autoPlaying is still true, so this explicit call is treated as another autoplay attempt, throws the same reduced-motion error, and dispatches auto-play-fail again → back to step 4, one level deeper.
A player.muted check in the listener does not stop it: player.muted = true reaches the provider, but the getter reads $state.muted, which only updates on the next (async) volume-change, so it still reads false on every nested call.
Logged from inside the listener (every nested call looks the same):
auto-play-fail #1: [vidstack] autoplay blocked | state.autoPlaying = true | muted = false
auto-play-fail #2: [vidstack] autoplay blocked | state.autoPlaying = true | muted = false
auto-play-fail #3: [vidstack] autoplay blocked | state.autoPlaying = true | muted = false
...
RangeError: Maximum call stack size exceeded
We hit this in production (React, onAutoPlayFail), almost entirely from iOS in-app browsers where "Reduce Motion" is enabled. Reproducing our production build locally, one page load goes ~550 dispatches deep and reports 60+ uncaught RangeErrors while the stack unwinds.
Expected Behavior:
auto-play-fail fires once, and a play() call made from its listener is an ordinary play request: it either plays or rejects once, without re-dispatching auto-play-fail synchronously.
Resetting autoPlaying before dispatching would do it (the same ordering exists in the play handler for auto-play):
if (event.autoPlay) {
autoPlaying.set(false);
this.handle(this.createEvent('auto-play-fail', { ... }));
}
With that change the listener's play() is no longer an autoplay attempt, so under reduced motion it would start playback — which seems right for an explicit call, but you may prefer a different policy there. Either way it should not be able to recurse.
Steps To Reproduce:
- Enable "Reduce motion" in the OS, or in Chrome DevTools: Rendering → Emulate CSS media feature prefers-reduced-motion →
reduce.
- Open the page below (single file, web components from the CDN — currently 1.15.6).
- Console shows the repeated
auto-play-fail logs, then RangeError: Maximum call stack size exceeded.
- Without reduced motion the same page works as intended: one
auto-play-fail (NotAllowedError), muted retry plays.
repro.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.vidstack.io/player/theme.css" />
<link rel="stylesheet" href="https://cdn.vidstack.io/player/video.css" />
<script type="module" src="https://cdn.vidstack.io/player"></script>
</head>
<body>
<media-player src="https://files.vidstack.io/sprite-fight/720p.mp4" autoplay playsinline>
<media-provider></media-provider>
<media-video-layout></media-video-layout>
</media-player>
<script type="module">
const player = document.querySelector("media-player");
let calls = 0;
player.addEventListener("auto-play-fail", (event) => {
calls++;
if (calls <= 3 || calls % 100 === 0) {
console.log(
`auto-play-fail #${calls}:`,
event.detail.error?.message,
"| state.autoPlaying =",
player.state.autoPlaying,
"| muted =",
player.muted,
);
}
// The usual fallback: retry muted.
player.muted = true;
player.play().catch(() => {});
});
</script>
</body>
</html>
Reproduction Link: single-file repro above (no build step needed).
Environment:
- Reproduced with
vidstack 1.15.6 (CDN, web components) and @vidstack/react 1.12.13 (Next.js 16)
- Browser: Chrome 153 (desktop, headless) with
prefers-reduced-motion: reduce emulated; in production: iOS Safari and the Instagram / Facebook in-app browsers with "Reduce Motion" on, plus one desktop Safari on macOS
- The ordering is still the same on
main (packages/vidstack/src/core/state/media-state-manager.ts, ['play-fail'])
Anything Else?
Workaround on the app side: guard the listener against re-entry (a flag set before calling play()), or defer the play() call to a microtask so it runs after autoPlaying is reset.
Related: #1479 (the reduced-motion autoplay block this interacts with).
Current Behavior:
Calling
player.play()from inside anauto-play-faillistener (the usual "retry muted" fallback) recurses synchronously untilRangeError: Maximum call stack size exceededwhen the visitor hasprefers-reduced-motion: reduce.The cycle:
#attemptAutoplaysetsautoPlaying = trueand callsplay().MediaRequestManager.playthrows synchronously inthrowIfAutoplayingWithReducedMotion(isAutoPlaying)— before the firstawait— so thecatchhandlesplay-failsynchronously (this.#stateMgr.handle(errorEvent)).MediaStateManager['play-fail']dispatchesauto-play-failbefore it runsautoPlaying.set(false):The listener calls
player.play().autoPlayingis stilltrue, so this explicit call is treated as another autoplay attempt, throws the same reduced-motion error, and dispatchesauto-play-failagain → back to step 4, one level deeper.A
player.mutedcheck in the listener does not stop it:player.muted = truereaches the provider, but the getter reads$state.muted, which only updates on the next (async)volume-change, so it still readsfalseon every nested call.Logged from inside the listener (every nested call looks the same):
We hit this in production (React,
onAutoPlayFail), almost entirely from iOS in-app browsers where "Reduce Motion" is enabled. Reproducing our production build locally, one page load goes ~550 dispatches deep and reports 60+ uncaughtRangeErrors while the stack unwinds.Expected Behavior:
auto-play-failfires once, and aplay()call made from its listener is an ordinary play request: it either plays or rejects once, without re-dispatchingauto-play-failsynchronously.Resetting
autoPlayingbefore dispatching would do it (the same ordering exists in theplayhandler forauto-play):With that change the listener's
play()is no longer an autoplay attempt, so under reduced motion it would start playback — which seems right for an explicit call, but you may prefer a different policy there. Either way it should not be able to recurse.Steps To Reproduce:
reduce.auto-play-faillogs, thenRangeError: Maximum call stack size exceeded.auto-play-fail(NotAllowedError), muted retry plays.repro.html
Reproduction Link: single-file repro above (no build step needed).
Environment:
vidstack1.15.6 (CDN, web components) and@vidstack/react1.12.13 (Next.js 16)prefers-reduced-motion: reduceemulated; in production: iOS Safari and the Instagram / Facebook in-app browsers with "Reduce Motion" on, plus one desktop Safari on macOSmain(packages/vidstack/src/core/state/media-state-manager.ts,['play-fail'])Anything Else?
Workaround on the app side: guard the listener against re-entry (a flag set before calling
play()), or defer theplay()call to a microtask so it runs afterautoPlayingis reset.Related: #1479 (the reduced-motion autoplay block this interacts with).