Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/core/src/types-hoist/vue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Vue 2/3 VM type.
*/
export interface VueViewModel {
// Vue3
__isVue?: boolean;
// Vue2
_isVue?: boolean;
}

/**
* Vue 3 VNode type.
*/
export interface VNode {
// Vue3
// https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168
__v_isVNode?: boolean;
}
18 changes: 9 additions & 9 deletions packages/core/src/utils/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { Primitive } from '../types-hoist/misc';
import type { ParameterizedString } from '../types-hoist/parameterize';
import type { PolymorphicEvent } from '../types-hoist/polymorphics';
import type { VNode, VueViewModel } from '../types-hoist/vue';

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

interface VueViewModel {
// Vue3
__isVue?: boolean;
// Vue2
_isVue?: boolean;
}
/**
* Checks whether given value's type is a Vue ViewModel.
* Checks whether given value's type is a Vue ViewModel or a VNode.
*
* @param wat A value to be checked.
* @returns A boolean representing the result.
*/
export function isVueViewModel(wat: unknown): boolean {
export function isVueViewModel(wat: unknown): wat is VueViewModel | VNode {
// Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property.
return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue));
// We also need to check for __v_isVNode because Vue 3 component render instances have an internal __v_isVNode property.
return !!(
typeof wat === 'object' &&
wat !== null &&
((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue || (wat as { __v_isVNode?: boolean }).__v_isVNode)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l/m: Could we add a comment here explaining a little bit what we're doing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea absolutely

);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/utils/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Primitive } from '../types-hoist/misc';
import { isSyntheticEvent, isVueViewModel } from './is';
import { convertToPlainObject } from './object';
import { getFunctionName } from './stacktrace';
import { getFunctionName, getVueInternalName } from './stacktrace';

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

if (isVueViewModel(value)) {
return '[VueViewModel]';
return getVueInternalName(value);
}

// React's SyntheticEvent thingy
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/utils/stacktrace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Event } from '../types-hoist/event';
import type { StackFrame } from '../types-hoist/stackframe';
import type { StackLineParser, StackParser } from '../types-hoist/stacktrace';
import type { VNode, VueViewModel } from '../types-hoist/vue';

const STACKTRACE_FRAME_LIMIT = 50;
export const UNKNOWN_FUNCTION = '?';
Expand Down Expand Up @@ -164,3 +165,15 @@ export function getFramesFromEvent(event: Event): StackFrame[] | undefined {
}
return undefined;
}

/**
* Get the internal name of an internal Vue value, to represent it in a stacktrace.
*
* @param value The value to get the internal name of.
*/
export function getVueInternalName(value: VueViewModel | VNode): string {
// Check if it's a VNode (has __v_isVNode) or a component instance (has _isVue/__isVue)
const isVNode = '__v_isVNode' in value && value.__v_isVNode;

return isVNode ? '[VueVNode]' : '[VueViewModel]';
}
3 changes: 2 additions & 1 deletion packages/core/src/utils/string.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isRegExp, isString, isVueViewModel } from './is';
import { getVueInternalName } from './stacktrace';

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

Expand Down Expand Up @@ -81,7 +82,7 @@ export function safeJoin(input: unknown[], delimiter?: string): string {
// Vue to issue another warning which repeats indefinitely.
// see: https://github.com/getsentry/sentry-javascript/pull/8981
if (isVueViewModel(value)) {
output.push('[VueViewModel]');
output.push(getVueInternalName(value));
} else {
output.push(String(value));
}
Expand Down
37 changes: 34 additions & 3 deletions packages/core/test/lib/utils/is.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,42 @@ describe('isInstanceOf()', () => {
});

describe('isVueViewModel()', () => {
test('should work as advertised', () => {
expect(isVueViewModel({ _isVue: true })).toEqual(true);
expect(isVueViewModel({ __isVue: true })).toEqual(true);
test('detects Vue 2 component instances with _isVue', () => {
const vue2Component = { _isVue: true, $el: {}, $data: {} };
expect(isVueViewModel(vue2Component)).toEqual(true);
});

test('detects Vue 3 component instances with __isVue', () => {
const vue3Component = { __isVue: true, $el: {}, $data: {} };
expect(isVueViewModel(vue3Component)).toEqual(true);
});

test('detects Vue 3 VNodes with __v_isVNode', () => {
const vueVNode = {
__v_isVNode: true,
__v_skip: true,
type: {},
props: {},
children: null,
};
expect(isVueViewModel(vueVNode)).toEqual(true);
});

test('does not detect plain objects', () => {
expect(isVueViewModel({ foo: true })).toEqual(false);
expect(isVueViewModel({ __v_skip: true })).toEqual(false); // __v_skip alone is not enough
expect(isVueViewModel({})).toEqual(false);
});

test('handles null and undefined', () => {
expect(isVueViewModel(null)).toEqual(false);
expect(isVueViewModel(undefined)).toEqual(false);
});

test('handles non-objects', () => {
expect(isVueViewModel('string')).toEqual(false);
expect(isVueViewModel(123)).toEqual(false);
expect(isVueViewModel(true)).toEqual(false);
});
});

Expand Down
51 changes: 51 additions & 0 deletions packages/vue/test/integration/VueIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,55 @@ describe('Sentry.VueIntegration', () => {
]);
expect(loggerWarnings).toEqual([]);
});

it('does not trigger warning spam when normalizing Vue VNodes with high normalizeDepth', () => {
// This test reproduces the issue from https://github.com/getsentry/sentry-javascript/issues/18203
// where VNodes in console arguments would trigger recursive warning spam with captureConsoleIntegration

Sentry.init({
dsn: PUBLIC_DSN,
defaultIntegrations: false,
normalizeDepth: 10, // High depth that would cause the issue
integrations: [Sentry.captureConsoleIntegration({ levels: ['warn'] })],
});

const initialWarningCount = warnings.length;

// Create a mock VNode that simulates the problematic behavior from the original issue
// In the real scenario, accessing VNode properties during normalization would trigger Vue warnings
// which would then be captured and normalized again, creating a recursive loop
let propertyAccessCount = 0;
const mockVNode = {
__v_isVNode: true,
__v_skip: true,
type: {},
get ctx() {
// Simulate Vue's behavior where accessing ctx triggers a warning
propertyAccessCount++;
// eslint-disable-next-line no-console
console.warn('[Vue warn]: compilerOptions warning triggered by property access');
return { uid: 1 };
},
get props() {
propertyAccessCount++;
return {};
},
};

// Pass the mock VNode to console.warn, simulating what Vue does
// Without the fix, Sentry would try to normalize mockVNode, access its ctx property,
// which triggers another warning, which gets captured and normalized, creating infinite recursion
// eslint-disable-next-line no-console
console.warn('[Vue warn]: Original warning', mockVNode);

// With the fix, Sentry detects the VNode early and stringifies it as [VueVNode]
// without accessing its properties, so propertyAccessCount stays at 0
expect(propertyAccessCount).toBe(0);

// Only 1 warning should be captured (the original one)
// Without the fix, the count would multiply as ctx getter warnings get recursively captured
const warningCountAfter = warnings.length;
const newWarnings = warningCountAfter - initialWarningCount;
expect(newWarnings).toBe(1);
});
});
Loading