From 059b535aacdcf7022e545b88820ee294cdec8b1f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 14 Nov 2025 15:35:59 +0200 Subject: [PATCH 1/3] fix(core): add VNode detection for vue objects --- packages/core/src/utils/is.ts | 6 ++- packages/core/src/utils/normalize.ts | 5 +- packages/core/test/lib/utils/is.test.ts | 37 ++++++++++++-- .../test/integration/VueIntegration.test.ts | 51 +++++++++++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/is.ts b/packages/core/src/utils/is.ts index 9ec498983d4a..bcd320fa0d0e 100644 --- a/packages/core/src/utils/is.ts +++ b/packages/core/src/utils/is.ts @@ -201,7 +201,11 @@ interface VueViewModel { */ export function isVueViewModel(wat: unknown): boolean { // 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)); + return !!( + typeof wat === 'object' && + wat !== null && + ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue || (wat as { __v_isVNode?: boolean }).__v_isVNode) + ); } /** diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index ba78d0cdb043..b9e7d0ffb5cb 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -217,7 +217,10 @@ function stringifyValue( } if (isVueViewModel(value)) { - return '[VueViewModel]'; + // Check if it's a VNode (has __v_isVNode) vs a component instance (has _isVue/__isVue) + const isVNode = (value as { __v_isVNode?: boolean }).__v_isVNode; + + return isVNode ? '[VueVNode]' : '[VueViewModel]'; } // React's SyntheticEvent thingy diff --git a/packages/core/test/lib/utils/is.test.ts b/packages/core/test/lib/utils/is.test.ts index 745cf275be06..092ffae2e3c3 100644 --- a/packages/core/test/lib/utils/is.test.ts +++ b/packages/core/test/lib/utils/is.test.ts @@ -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); }); }); diff --git a/packages/vue/test/integration/VueIntegration.test.ts b/packages/vue/test/integration/VueIntegration.test.ts index 81eb22254917..62ff990d1f43 100644 --- a/packages/vue/test/integration/VueIntegration.test.ts +++ b/packages/vue/test/integration/VueIntegration.test.ts @@ -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); + }); }); From 4108e9e1866cfb49897ddffa83d022e67a135328 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 14 Nov 2025 22:07:21 +0200 Subject: [PATCH 2/3] refactor: extract name resolution to a util --- packages/core/src/utils/is.ts | 4 +++- packages/core/src/utils/normalize.ts | 7 ++----- packages/core/src/utils/stacktrace.ts | 13 +++++++++++++ packages/core/src/utils/string.ts | 3 ++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/is.ts b/packages/core/src/utils/is.ts index bcd320fa0d0e..fce4b48dba28 100644 --- a/packages/core/src/utils/is.ts +++ b/packages/core/src/utils/is.ts @@ -194,13 +194,15 @@ interface VueViewModel { _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 { // Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property. + // We also need to check for __v_isVNode because Vue 3 component render instances have an internal __v_isVNode property. + // https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168 return !!( typeof wat === 'object' && wat !== null && diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index b9e7d0ffb5cb..d70033d65672 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -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 @@ -217,10 +217,7 @@ function stringifyValue( } if (isVueViewModel(value)) { - // Check if it's a VNode (has __v_isVNode) vs a component instance (has _isVue/__isVue) - const isVNode = (value as { __v_isVNode?: boolean }).__v_isVNode; - - return isVNode ? '[VueVNode]' : '[VueViewModel]'; + return getVueInternalName(value); } // React's SyntheticEvent thingy diff --git a/packages/core/src/utils/stacktrace.ts b/packages/core/src/utils/stacktrace.ts index c7aab77bf3be..16e99ffb9550 100644 --- a/packages/core/src/utils/stacktrace.ts +++ b/packages/core/src/utils/stacktrace.ts @@ -164,3 +164,16 @@ 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: unknown): string { + // Check if it's a VNode (has __v_isVNode) vs a component instance (has _isVue/__isVue) + // https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168 + const isVNode = (value as { __v_isVNode?: boolean }).__v_isVNode; + + return isVNode ? '[VueVNode]' : '[VueViewModel]'; +} diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 34a1abd4eb46..b74f9559f9cf 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -1,4 +1,5 @@ import { isRegExp, isString, isVueViewModel } from './is'; +import { getVueInternalName } from './stacktrace'; export { escapeStringForRegex } from '../vendor/escapeStringForRegex'; @@ -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)); } From 82e2a8d52a702aa8df73e08f130e4f2b99b62bb1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 14 Nov 2025 22:13:45 +0200 Subject: [PATCH 3/3] ref: make the checks more typesafe --- packages/core/src/types-hoist/vue.ts | 18 ++++++++++++++++++ packages/core/src/utils/is.ts | 10 ++-------- packages/core/src/utils/stacktrace.ts | 8 ++++---- 3 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/types-hoist/vue.ts diff --git a/packages/core/src/types-hoist/vue.ts b/packages/core/src/types-hoist/vue.ts new file mode 100644 index 000000000000..f83be3c5fd28 --- /dev/null +++ b/packages/core/src/types-hoist/vue.ts @@ -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; +} diff --git a/packages/core/src/utils/is.ts b/packages/core/src/utils/is.ts index fce4b48dba28..9abfab910099 100644 --- a/packages/core/src/utils/is.ts +++ b/packages/core/src/utils/is.ts @@ -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; @@ -187,22 +188,15 @@ 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 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. // We also need to check for __v_isVNode because Vue 3 component render instances have an internal __v_isVNode property. - // https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168 return !!( typeof wat === 'object' && wat !== null && diff --git a/packages/core/src/utils/stacktrace.ts b/packages/core/src/utils/stacktrace.ts index 16e99ffb9550..6b50caf48b30 100644 --- a/packages/core/src/utils/stacktrace.ts +++ b/packages/core/src/utils/stacktrace.ts @@ -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 = '?'; @@ -170,10 +171,9 @@ export function getFramesFromEvent(event: Event): StackFrame[] | undefined { * * @param value The value to get the internal name of. */ -export function getVueInternalName(value: unknown): string { - // Check if it's a VNode (has __v_isVNode) vs a component instance (has _isVue/__isVue) - // https://github.com/vuejs/core/blob/main/packages/runtime-core/src/vnode.ts#L168 - const isVNode = (value as { __v_isVNode?: boolean }).__v_isVNode; +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]'; }