Skip to content

play() inside an auto-play-fail listener recurses until stack overflow under prefers-reduced-motion: reduce #1862

Description

@williamfusiger

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:

  1. #attemptAutoplay sets autoPlaying = true and calls play().

  2. MediaRequestManager.play throws synchronously in throwIfAutoplayingWithReducedMotion(isAutoPlaying) — before the first await — so the catch handles play-fail synchronously (this.#stateMgr.handle(errorEvent)).

  3. 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
    }
  4. 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:

  1. Enable "Reduce motion" in the OS, or in Chrome DevTools: Rendering → Emulate CSS media feature prefers-reduced-motion → reduce.
  2. Open the page below (single file, web components from the CDN — currently 1.15.6).
  3. Console shows the repeated auto-play-fail logs, then RangeError: Maximum call stack size exceeded.
  4. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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