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 MediaPlayer — JSON.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>
);
}
- Load the page with the browser console open.
- Expected: vidstack prints a "Media Error" group.
- 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"] → undefined → undefined() → 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
-
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, ... }.
-
Logger.dispatch → vds-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).
-
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.
-
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.
Current Behavior:
Reading any property that is not in the signal record off a component's
stateproxy throwsTypeError: this.$state[prop] is not a function. The most common such property istoJSON, which every JSON serializer probes for. So anything that walks aMediaPlayer—JSON.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) intoconsole.log, and Next's dev console forwarder serializes everyconsole.*argument. The throw escapes inside Next'sargs.map(...), so the Next dev error overlay opens with a single stack frame,at Array.map (<anonymous>).Expected Behavior:
Reading an absent property off
statereturnsundefined. Serializers walking a player do not throw.Steps To Reproduce:
0. Isolation test (no framework, no Next, one line). With any mounted player instance:
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:TypeError: this.$state[prop] is not a function, single frameat Array.map (<anonymous>).No
logLevelconfiguration 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:
@vidstack/react: 1.15.6 (bundling maverick.js 0.44.1)MediaPlayer+MediaProviderAnything Else?
The defect
Bundled at
node_modules/@vidstack/react/dev/chunks/vidstack-CWZPUheZ.js:841, from the region markedmaverick.js@0.44.1/dist/dev/controller.js— in theInstanceconstructor (var Instance = classat:815):The
gettrap is unguarded: it assumes every property read corresponds to a signal and calls the result. Probing for a conventional-but-absent property givesthis.$state["toJSON"]→undefined→undefined()→TypeError.toJSONis never populated — grepping it across the entire installed@vidstack/reactpackage 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 viaget state()at:978.The library already has the correct guard, one file over
useSignalRecord's proxy,vidstack-CWZPUheZ.js:1517(regionmaverick.js@0.44.1/dist/dev/react.js):How Next reaches it without any app code
vidstack logs an error group carrying the media context. e.g.
#logError,packages/vidstack/src/core/state/media-request-manager.ts(bundledvidstack-DxAMdmBt.js:3199-3200, reformatted from one physical line):The media context contains the component instance —
packages/vidstack/src/components/player.ts(:4308-4317):const context = { player: this, ... }.Logger.dispatch→vds-logevent →LogPrinter(packages/vidstack/src/foundation/logger/log-printer.ts).printGroup(:4178-4185) dispatches labelled entries tolabelledPrint(:4170-4171):so the effective call is
console.log("%cMedia Context:", "color: gray", ctx).Next's dev console patch serializes every argument, unguarded.
node_modules/next/dist/next-devtools/userspace/app/forward-logs.js:50-65:safeStringifyWithDepthissafe-stable-stringifyconfigured{ maximumDepth: 5, maximumBreadth: 100 }(forward-logs-utils.js:33-38). ItsstringifySimplerunsif (typeof s.toJSON === "function")before theif (u < l.length + 1) return '"[Object]"'depth cutoff, somaximumDepthdoes not prevent the probe.Three details that make this unavoidable in App Router dev:
next-devtools/userspace/app/app-dev-overlay-setup.js:10callsinitializeDebugLogForwarding('app')at module top level — installed by default.patchConsoleMethod(next-devtools/shared/forward-logs-shared.js:24-31) callswrapper(methodName, ...args)unguarded, beforeoriginalMethod.apply. Thetry/catchinforward-logs.js:482-492wraps patch installation, not invocation.createLogEntry(forward-logs.js:298-303) callsclientFileLogger.log(level, args)before theisTerminalLoggingEnabledearly return, so terminal-logging config doesn't gate it either.The walk reaches the Proxy via
ctx → player → $$ → state. Both hops are own-enumerable plain assignments —attach({ $$ }) { this.$$ = $$; }(:985-986) andthis.state = new Proxy(...)(:841) — so aObject.keyswalk sees them at depth 3, inside the depth-5 budget. (The prototypeget state()at:978is invisible toObject.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:LogLevelValue(:4088-4094) issilent: 0, error: 1, warn: 2, info: 3, debug: 4. DefaultlogLevelis"warn"(player-props.ts,:2350), and an error group is level 1:2 < 1is 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
:1517to theInstanceproxy at:841:One nitpick worth pre-empting:
inwalks the prototype chain, and$stateis a plain{}(store = {}at:1113), so'toString' in $stateand'constructor' in $statearetrueand would callObject.prototype.toString/Object, returning junk instead of a signal value. Harmless forsafe-stable-stringify(it only probestoJSON), andinis consistent with:1517— butObject.hasOwn(this.$state, prop)is the tighter variant if you'd rather.While you're in that file:
State.createat:1113has the same unguarded shape —— though it's not on this crash path (it's bound as
thisfor computed getters and never returned;create()returnsstore).Since the Proxy lives in maverick's
controller.js, the fix presumably belongs upstream (I'd guesssrc/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
silent(0)< error(1) makes the gate at:4138return before anyconsolecall, 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 aMediaPlayerin 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) setsthis.state = EMPTY_PROPS(:909,EMPTY_PROPS = {}at:814) and thenthis.$state = null(:910). Post-destroy reads through the replacedstateyieldundefinedwithout throwing; a proxy captured before destroy throwsCannot 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.