Skip to content

Commit fcf35f6

Browse files
authored
fix(core/vue): Detect and skip normalizing Vue VNode objects with high normalizeDepth (#18206)
Fixes #18203 ### Problem When using `normalizeDepth: 10` with `captureConsoleIntegration` enabled, Vue VNodes in console arguments would trigger recursive warning spam. Accessing VNode properties during normalization would trigger Vue's reactive getters, which emit console warnings. These warnings would then be captured and normalized again, creating a recursive loop that could generate hundreds of warnings. Note that this only happens in `dev` mode ### Solution Changed `isVueViewModel()` to detect Vue 3 VNodes (`__v_isVNode: true`) in addition to Vue 2/3 ViewModels. VNodes are now identified early in the normalization process and stringified as `[VueVNode]` before their properties are accessed, preventing the recursive warning loop. Some of the properties on the `VNode` can also be reactive, so it can incorrectly add those to a watchEffect or a render function reactive dependencies when accessed by the normalizer. ### Changes - **`packages/core/src/utils/is.ts`**: Added `__v_isVNode` check to `isVueViewModel()`. - **`packages/core/src/utils/normalize.ts`**: Distinguish VNodes from ViewModels in output (`[VueVNode]` vs `[VueViewModel]`). - **Tests**: Added comprehensive unit tests for Vue object detection and integration test that verifies no property access occurs during VNode normalization. --- I couldn't reproduce this exactly in a test with a real vue component, but verified it fixes the reproduction example. The before and after of the captured logs: Before: <img width="1106" height="1137" alt="CleanShot 2025-11-14 at 15 46 30" src="https://github.com/user-attachments/assets/435dbb04-ba3c-430b-8c39-d886f92072e8" /> After: <img width="908" height="768" alt="CleanShot 2025-11-14 at 15 45 15" src="https://github.com/user-attachments/assets/e7d8cca2-a0e1-48bb-9f95-3a39d2164d21" /> As a Vue developer I don't think the loss of information here would help debug anything.
1 parent ea3183f commit fcf35f6

File tree

7 files changed

+129
-15
lines changed

7 files changed

+129
-15
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Vue 2/3 VM type.
3+
*/
4+
export interface VueViewModel {
5+
// Vue3
6+
__isVue?: boolean;
7+
// Vue2
8+
_isVue?: boolean;
9+
}
10+
11+
/**
12+
* Vue 3 VNode type.
13+
*/
14+
export interface VNode {
15+
// Vue3
16+
// https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168
17+
__v_isVNode?: boolean;
18+
}

packages/core/src/utils/is.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { Primitive } from '../types-hoist/misc';
44
import type { ParameterizedString } from '../types-hoist/parameterize';
55
import type { PolymorphicEvent } from '../types-hoist/polymorphics';
6+
import type { VNode, VueViewModel } from '../types-hoist/vue';
67

78
// eslint-disable-next-line @typescript-eslint/unbound-method
89
const objectToString = Object.prototype.toString;
@@ -187,21 +188,20 @@ export function isInstanceOf(wat: any, base: any): boolean {
187188
}
188189
}
189190

190-
interface VueViewModel {
191-
// Vue3
192-
__isVue?: boolean;
193-
// Vue2
194-
_isVue?: boolean;
195-
}
196191
/**
197-
* Checks whether given value's type is a Vue ViewModel.
192+
* Checks whether given value's type is a Vue ViewModel or a VNode.
198193
*
199194
* @param wat A value to be checked.
200195
* @returns A boolean representing the result.
201196
*/
202-
export function isVueViewModel(wat: unknown): boolean {
197+
export function isVueViewModel(wat: unknown): wat is VueViewModel | VNode {
203198
// Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property.
204-
return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue));
199+
// We also need to check for __v_isVNode because Vue 3 component render instances have an internal __v_isVNode property.
200+
return !!(
201+
typeof wat === 'object' &&
202+
wat !== null &&
203+
((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue || (wat as { __v_isVNode?: boolean }).__v_isVNode)
204+
);
205205
}
206206

207207
/**

packages/core/src/utils/normalize.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Primitive } from '../types-hoist/misc';
22
import { isSyntheticEvent, isVueViewModel } from './is';
33
import { convertToPlainObject } from './object';
4-
import { getFunctionName } from './stacktrace';
4+
import { getFunctionName, getVueInternalName } from './stacktrace';
55

66
type Prototype = { constructor?: (...args: unknown[]) => unknown };
77
// This is a hack to placate TS, relying on the fact that technically, arrays are objects with integer keys. Normally we
@@ -217,7 +217,7 @@ function stringifyValue(
217217
}
218218

219219
if (isVueViewModel(value)) {
220-
return '[VueViewModel]';
220+
return getVueInternalName(value);
221221
}
222222

223223
// React's SyntheticEvent thingy

packages/core/src/utils/stacktrace.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Event } from '../types-hoist/event';
22
import type { StackFrame } from '../types-hoist/stackframe';
33
import type { StackLineParser, StackParser } from '../types-hoist/stacktrace';
4+
import type { VNode, VueViewModel } from '../types-hoist/vue';
45

56
const STACKTRACE_FRAME_LIMIT = 50;
67
export const UNKNOWN_FUNCTION = '?';
@@ -164,3 +165,15 @@ export function getFramesFromEvent(event: Event): StackFrame[] | undefined {
164165
}
165166
return undefined;
166167
}
168+
169+
/**
170+
* Get the internal name of an internal Vue value, to represent it in a stacktrace.
171+
*
172+
* @param value The value to get the internal name of.
173+
*/
174+
export function getVueInternalName(value: VueViewModel | VNode): string {
175+
// Check if it's a VNode (has __v_isVNode) or a component instance (has _isVue/__isVue)
176+
const isVNode = '__v_isVNode' in value && value.__v_isVNode;
177+
178+
return isVNode ? '[VueVNode]' : '[VueViewModel]';
179+
}

packages/core/src/utils/string.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isRegExp, isString, isVueViewModel } from './is';
2+
import { getVueInternalName } from './stacktrace';
23

34
export { escapeStringForRegex } from '../vendor/escapeStringForRegex';
45

@@ -81,7 +82,7 @@ export function safeJoin(input: unknown[], delimiter?: string): string {
8182
// Vue to issue another warning which repeats indefinitely.
8283
// see: https://github.com/getsentry/sentry-javascript/pull/8981
8384
if (isVueViewModel(value)) {
84-
output.push('[VueViewModel]');
85+
output.push(getVueInternalName(value));
8586
} else {
8687
output.push(String(value));
8788
}

packages/core/test/lib/utils/is.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,42 @@ describe('isInstanceOf()', () => {
121121
});
122122

123123
describe('isVueViewModel()', () => {
124-
test('should work as advertised', () => {
125-
expect(isVueViewModel({ _isVue: true })).toEqual(true);
126-
expect(isVueViewModel({ __isVue: true })).toEqual(true);
124+
test('detects Vue 2 component instances with _isVue', () => {
125+
const vue2Component = { _isVue: true, $el: {}, $data: {} };
126+
expect(isVueViewModel(vue2Component)).toEqual(true);
127+
});
128+
129+
test('detects Vue 3 component instances with __isVue', () => {
130+
const vue3Component = { __isVue: true, $el: {}, $data: {} };
131+
expect(isVueViewModel(vue3Component)).toEqual(true);
132+
});
133+
134+
test('detects Vue 3 VNodes with __v_isVNode', () => {
135+
const vueVNode = {
136+
__v_isVNode: true,
137+
__v_skip: true,
138+
type: {},
139+
props: {},
140+
children: null,
141+
};
142+
expect(isVueViewModel(vueVNode)).toEqual(true);
143+
});
127144

145+
test('does not detect plain objects', () => {
128146
expect(isVueViewModel({ foo: true })).toEqual(false);
147+
expect(isVueViewModel({ __v_skip: true })).toEqual(false); // __v_skip alone is not enough
148+
expect(isVueViewModel({})).toEqual(false);
149+
});
150+
151+
test('handles null and undefined', () => {
152+
expect(isVueViewModel(null)).toEqual(false);
153+
expect(isVueViewModel(undefined)).toEqual(false);
154+
});
155+
156+
test('handles non-objects', () => {
157+
expect(isVueViewModel('string')).toEqual(false);
158+
expect(isVueViewModel(123)).toEqual(false);
159+
expect(isVueViewModel(true)).toEqual(false);
129160
});
130161
});
131162

packages/vue/test/integration/VueIntegration.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,55 @@ describe('Sentry.VueIntegration', () => {
9393
]);
9494
expect(loggerWarnings).toEqual([]);
9595
});
96+
97+
it('does not trigger warning spam when normalizing Vue VNodes with high normalizeDepth', () => {
98+
// This test reproduces the issue from https://github.com/getsentry/sentry-javascript/issues/18203
99+
// where VNodes in console arguments would trigger recursive warning spam with captureConsoleIntegration
100+
101+
Sentry.init({
102+
dsn: PUBLIC_DSN,
103+
defaultIntegrations: false,
104+
normalizeDepth: 10, // High depth that would cause the issue
105+
integrations: [Sentry.captureConsoleIntegration({ levels: ['warn'] })],
106+
});
107+
108+
const initialWarningCount = warnings.length;
109+
110+
// Create a mock VNode that simulates the problematic behavior from the original issue
111+
// In the real scenario, accessing VNode properties during normalization would trigger Vue warnings
112+
// which would then be captured and normalized again, creating a recursive loop
113+
let propertyAccessCount = 0;
114+
const mockVNode = {
115+
__v_isVNode: true,
116+
__v_skip: true,
117+
type: {},
118+
get ctx() {
119+
// Simulate Vue's behavior where accessing ctx triggers a warning
120+
propertyAccessCount++;
121+
// eslint-disable-next-line no-console
122+
console.warn('[Vue warn]: compilerOptions warning triggered by property access');
123+
return { uid: 1 };
124+
},
125+
get props() {
126+
propertyAccessCount++;
127+
return {};
128+
},
129+
};
130+
131+
// Pass the mock VNode to console.warn, simulating what Vue does
132+
// Without the fix, Sentry would try to normalize mockVNode, access its ctx property,
133+
// which triggers another warning, which gets captured and normalized, creating infinite recursion
134+
// eslint-disable-next-line no-console
135+
console.warn('[Vue warn]: Original warning', mockVNode);
136+
137+
// With the fix, Sentry detects the VNode early and stringifies it as [VueVNode]
138+
// without accessing its properties, so propertyAccessCount stays at 0
139+
expect(propertyAccessCount).toBe(0);
140+
141+
// Only 1 warning should be captured (the original one)
142+
// Without the fix, the count would multiply as ctx getter warnings get recursively captured
143+
const warningCountAfter = warnings.length;
144+
const newWarnings = warningCountAfter - initialWarningCount;
145+
expect(newWarnings).toBe(1);
146+
});
96147
});

0 commit comments

Comments
 (0)