Skip to content

state Proxy throws on any absent-property read (e.g. toJSON) — JSON.stringify(player) crashes, and so does the Next.js dev log forwarder #1847

Description

@pedrolbacelar

Current Behavior:

Reading any property that is not in the signal record off a component's state proxy throws TypeError: this.$state[prop] is not a function. The most common such property is toJSON, which every JSON serializer probes for. So anything that walks a MediaPlayerJSON.stringify, a structured logger, a devtools inspector — throws.

In Next.js dev this happens with no application code involved: vidstack's own error logger passes the live media context (which holds player) into console.log, and Next's dev console forwarder serializes every console.* argument. The throw escapes inside Next's args.map(...), so the Next dev error overlay opens with a single stack frame, at Array.map (<anonymous>).

Expected Behavior:

Reading an absent property off state returns undefined. Serializers walking a player do not throw.

Steps To Reproduce:

0. Isolation test (no framework, no Next, one line). With any mounted player instance:

player.$$.state.toJSON   // TypeError: this.$state[prop] is not a function
JSON.stringify(player)   // same

This is the whole defect. Everything below is just one way it gets triggered for you automatically.

1. Full crash in a bare Next 16 app (Turbopack dev, App Router). app/page.tsx:

'use client';

import { MediaPlayer, MediaProvider } from '@vidstack/react';
import '@vidstack/react/player/styles/base.css';

export default function Page() {
  return (
    // A 404 src makes the <video> element emit an error event, which routes to
    // the "Media Error" error group in media-state-manager.ts — an error-level
    // log that carries the media context (and therefore the player).
    <MediaPlayer src="https://files.vidstack.io/sprite-fight/does-not-exist.mp4">
      <MediaProvider />
    </MediaPlayer>
  );
}
  1. Load the page with the browser console open.
  2. Expected: vidstack prints a "Media Error" group.
  3. Actual: the Next dev overlay opens with TypeError: this.$state[prop] is not a function, single frame at Array.map (<anonymous>).

No logLevel configuration is needed — see "Why it looks intermittent" below.

Reproduction Link: I don't have a StackBlitz for this one, and I'd argue the isolation test in step 0 makes one unnecessary — the defect is a two-line proxy in maverick and it reproduces on any mounted player, in any framework, with JSON.stringify(player). Happy to put one together if you want the full Next overlay path demonstrated rather than the proxy itself.

Environment:

  • Framework: React 19.2.4
  • Meta Framework: Next.js 16.2.10 (Turbopack, dev, App Router)
  • @vidstack/react: 1.15.6 (bundling maverick.js 0.44.1)
  • Node: 22.22.2
  • Device: Desktop
  • Browser: Chrome
  • Setup: MediaPlayer + MediaProvider

Anything Else?

The defect

Bundled at node_modules/@vidstack/react/dev/chunks/vidstack-CWZPUheZ.js:841, from the region marked maverick.js@0.44.1/dist/dev/controller.js — in the Instance constructor (var Instance = class at :815):

this.state = new Proxy(this.$state, { get: (_, prop) => this.$state[prop]() });

The get trap is unguarded: it assumes every property read corresponds to a signal and calls the result. Probing for a conventional-but-absent property gives this.$state["toJSON"]undefinedundefined()TypeError.

toJSON is never populated — grepping it across the entire installed @vidstack/react package returns zero hits. The probe is guaranteed to hit the throwing path.

Note the class attribution: the Proxy is built in maverick's Instance. ViewController (:954) only re-exposes it via get state() at :978.

The library already has the correct guard, one file over

useSignalRecord's proxy, vidstack-CWZPUheZ.js:1517 (region maverick.js@0.44.1/dist/dev/react.js):

if (!props.has(prop) && prop in $state) {

How Next reaches it without any app code

  1. vidstack logs an error group carrying the media context. e.g. #logError, packages/vidstack/src/core/state/media-request-manager.ts (bundled vidstack-DxAMdmBt.js:3199-3200, reformatted from one physical line):

    #logError(title, error, request) {
      this.#media.logger?.errorGroup(`[vidstack] ${title}`)
        .labelledLog("Error", error)
        .labelledLog("Media Context", { ...this.#media })
        .labelledLog("Trigger Event", request)
        .dispatch();
    }

    The media context contains the component instance — packages/vidstack/src/components/player.ts (:4308-4317): const context = { player: this, ... }.

  2. Logger.dispatchvds-log event → LogPrinter (packages/vidstack/src/foundation/logger/log-printer.ts). printGroup (:4178-4185) dispatches labelled entries to labelledPrint (:4170-4171):

    function labelledPrint(label, ...data) {
      console.log(`%c${label}:`, "color: gray", ...data);
    }

    so the effective call is console.log("%cMedia Context:", "color: gray", ctx).

  3. Next's dev console patch serializes every argument, unguarded. node_modules/next/dist/next-devtools/userspace/app/forward-logs.js:50-65:

    log(level, args) {
      ...
      const message = args.map((arg)=>{
        ...
        if (arg instanceof Element) return `<${arg.tagName.toLowerCase()}>`;
        return safeStringifyWithDepth(arg);
      }).join(' ');

    safeStringifyWithDepth is safe-stable-stringify configured { maximumDepth: 5, maximumBreadth: 100 } (forward-logs-utils.js:33-38). Its stringifySimple runs if (typeof s.toJSON === "function") before the if (u < l.length + 1) return '"[Object]"' depth cutoff, so maximumDepth does not prevent the probe.

    Three details that make this unavoidable in App Router dev:

    • next-devtools/userspace/app/app-dev-overlay-setup.js:10 calls initializeDebugLogForwarding('app') at module top level — installed by default.
    • patchConsoleMethod (next-devtools/shared/forward-logs-shared.js:24-31) calls wrapper(methodName, ...args) unguarded, before originalMethod.apply. The try/catch in forward-logs.js:482-492 wraps patch installation, not invocation.
    • createLogEntry (forward-logs.js:298-303) calls clientFileLogger.log(level, args) before the isTerminalLoggingEnabled early return, so terminal-logging config doesn't gate it either.
  4. The walk reaches the Proxy via ctx → player → $$ → state. Both hops are own-enumerable plain assignments — attach({ $$ }) { this.$$ = $$; } (:985-986) and this.state = new Proxy(...) (:841) — so a Object.keys walk sees them at depth 3, inside the depth-5 budget. (The prototype get state() at :978 is invisible to Object.keys, but the $$ route reaches the same Proxy.) This step is traced from the source rather than instrumented; the step-0 one-liner demonstrates the throw directly regardless.

Why it looks intermittent

It isn't. LogPrinter, :4138:

if (LogLevelValue[this.#level] < LogLevelValue[level]) return;

LogLevelValue (:4088-4094) is silent: 0, error: 1, warn: 2, info: 3, debug: 4. Default logLevel is "warn" (player-props.ts, :2350), and an error group is level 1: 2 < 1 is false, so error groups always print at the default log level. The crash fires whenever vidstack emits any player-carrying error log — which depends on runtime conditions (autoplay policy, network, fullscreen/orientation availability), not on log config.

Player-carrying error-level log sites: #logError (:3199, 9 call sites — play, pause, airplay, cast, fullscreen, orientation lock/unlock, PiP, seek-to-live-edge), plus "Media Error" (:3577), "failed to load source" (:4993), and force-exit-PiP (:2870).

Suggested fix

Apply the guard the library already uses at :1517 to the Instance proxy at :841:

this.state = new Proxy(this.$state, {
  get: (_, prop) => (prop in this.$state ? this.$state[prop]() : undefined),
});

One nitpick worth pre-empting: in walks the prototype chain, and $state is a plain {} (store = {} at :1113), so 'toString' in $state and 'constructor' in $state are true and would call Object.prototype.toString / Object, returning junk instead of a signal value. Harmless for safe-stable-stringify (it only probes toJSON), and in is consistent with :1517 — but Object.hasOwn(this.$state, prop) is the tighter variant if you'd rather.

While you're in that file: State.create at :1113 has the same unguarded shape —

const store = {}, state = new Proxy(store, { get: (_, prop) => store[prop]() });

— though it's not on this crash path (it's bound as this for computed getters and never returned; create() returns store).

Since the Proxy lives in maverick's controller.js, the fix presumably belongs upstream (I'd guess src/core/instance.ts, but I'm inferring that from the bundled output, not from the source). Happy to open a PR wherever you'd like it if this looks right.

Workaround

<MediaPlayer logLevel="silent" ...>

silent (0) < error (1) makes the gate at :4138 return before any console call, so nothing hands the player to a serializer. Cost: no vidstack console diagnostics in dev.

Scope note

The trigger is dev-only; the defect is not. The unguarded Proxy also ships in the production build — node_modules/@vidstack/react/prod/chunks/vidstack-B9fTkxQO.js:837, byte-identical. What keeps it quiet there is only the absence of the Logger — nothing walks the player. Any code that traverses a MediaPlayer in a production build — a serializer, an inspector, anything doing a structured walk — would hit the same throw.

Why I don't think this is a teardown race

destroy() (:891-912) sets this.state = EMPTY_PROPS (:909, EMPTY_PROPS = {} at :814) and then this.$state = null (:910). Post-destroy reads through the replaced state yield undefined without throwing; a proxy captured before destroy throws Cannot read properties of null — a different message than the one here. Also, the signal stores are append-only (State.reset, :1120-1121, only calls .set()), so a key cannot vanish from under the trap.

Possibly related

#1753 reports the same error text on Next 16 + React 19. I can't tell from here whether it's this path or the teardown one the author describes — flagging it in case the guard above turns out to cover both.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions