55import { spanToJSON } from '@sentry/core' ;
66import type { MockInstance } from 'vitest' ;
77import { afterEach , beforeEach , describe , expect , it as baseIt , vi } from 'vitest' ;
8- import type { App } from 'vue' ;
9- import { createApp , h } from 'vue' ;
8+ import type { App , Ref } from 'vue' ;
9+ import { createApp , h , nextTick , ref } from 'vue' ;
1010import * as Sentry from '../../src' ;
1111import type { Options , TracingOptions } from '../../src/types' ;
1212
@@ -19,6 +19,7 @@ const SENTRY_ORIGIN_ATTRIBUTE = 'sentry.origin';
1919const VUE_SPAN_ORIGIN = 'auto.ui.vue' ;
2020const UI_MOUNT_SPAN_OP = 'ui.mount' ;
2121const UI_RENDER_SPAN_OP = 'ui.render' ;
22+ const UI_UPDATE_SPAN_OP = 'ui.update' ;
2223
2324interface UiSpan {
2425 name : string ;
@@ -34,6 +35,14 @@ function createTestApp(): App {
3435 return createApp ( { name : 'RootComponent' , render : ( ) => h ( 'div' , [ h ( child ) ] ) } ) ;
3536}
3637
38+ /** Like `createTestApp`, but the root render reads a ref, so mutating it re-renders the root. */
39+ function createReactiveTestApp ( ) : { app : App ; message : Ref < string > } {
40+ const message = ref ( 'initial message' ) ;
41+ const child = { name : 'ChildComponent' , render : ( ) => h ( 'p' , 'child' ) } ;
42+ const app = createApp ( { name : 'RootComponent' , render : ( ) => h ( 'div' , [ h ( 'span' , message . value ) , h ( child ) ] ) } ) ;
43+ return { app, message } ;
44+ }
45+
3746/** Reads the mixins Vue accepted. `app.mixin()` is a silent no-op without the Options API. */
3847function getRegisteredMixins ( app : App ) : unknown [ ] {
3948 return ( app as unknown as { _context : { mixins : unknown [ ] } } ) . _context . mixins ;
@@ -173,6 +182,72 @@ describe('tracing mixin span creation', () => {
173182 ] ) ;
174183 } ) ;
175184
185+ // The mixin always tracks the root component: `isRootComponent || …` short-circuits before the
186+ // `trackComponents` filter runs. The next four tests record what that means for each hook, so a
187+ // mixin replacement can prove which parts it keeps.
188+
189+ it ( 'tracks the root component for update hooks without trackComponents' , async ( { uiSpans, initSentry } ) => {
190+ const { app, message } = createReactiveTestApp ( ) ;
191+ initSentry ( { tracing : { hooks : [ 'update' ] } , sdk : { app } } ) ;
192+ const container = document . createElement ( 'div' ) ;
193+
194+ await Sentry . startSpan ( { name : 'pageload' } , async ( ) => {
195+ app . mount ( container ) ;
196+ message . value = 'updated message' ;
197+ // Works under fake timers: Vue flushes re-renders through microtasks, not timers.
198+ await nextTick ( ) ;
199+ vi . advanceTimersByTime ( ROOT_SPAN_TIMEOUT_MS + 1 ) ;
200+ } ) ;
201+
202+ expect ( uiSpans ) . toEqual ( [
203+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } ,
204+ { name : 'Vue <Root>' , op : UI_UPDATE_SPAN_OP } ,
205+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
206+ ] ) ;
207+ } ) ;
208+
209+ // `beforeCreate` fires very early in `app.mount()`, but the mixin creates the root render span
210+ // first, in the same handler. So the `create` span has a parent and is emitted, as `ui.mount`,
211+ // which is the op the `create` operation maps to.
212+ it ( 'tracks the root component for create hooks without trackComponents' , ( { app, uiSpans, initSentry } ) => {
213+ initSentry ( { tracing : { hooks : [ 'create' ] } } ) ;
214+
215+ mountUnderActiveSpan ( app ) ;
216+
217+ expect ( uiSpans ) . toEqual ( [
218+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } , // create
219+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } , // mount (DEFAULT_HOOKS is always merged in)
220+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
221+ ] ) ;
222+ } ) ;
223+
224+ // `activate` maps to the `activated`/`deactivated` hooks, which Vue only calls inside
225+ // `<KeepAlive>`. A root component is never kept alive, so `activate` produces no root span.
226+ it ( 'does not track the root component for activate hooks' , ( { app, uiSpans, initSentry } ) => {
227+ initSentry ( { tracing : { hooks : [ 'activate' ] } } ) ;
228+
229+ mountUnderActiveSpan ( app ) ;
230+
231+ expect ( uiSpans ) . toEqual ( [
232+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } ,
233+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
234+ ] ) ;
235+ } ) ;
236+
237+ // Component spans are keyed by operation, not by span op, so `create` and `mount` each emit their
238+ // own root span even though both map to `ui.mount`.
239+ it ( 'emits two root mount spans when both create and mount hooks are enabled' , ( { app, uiSpans, initSentry } ) => {
240+ initSentry ( { tracing : { hooks : [ 'create' , 'mount' ] } } ) ;
241+
242+ mountUnderActiveSpan ( app ) ;
243+
244+ expect ( uiSpans ) . toEqual ( [
245+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } , // create
246+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } , // mount
247+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
248+ ] ) ;
249+ } ) ;
250+
176251 // Vue 3 compiles `app.mixin()` down to a no-op returning the app when the `__VUE_OPTIONS_API__`
177252 // build flag is `false`. Nuxt 5 sets that flag by default (nuxt/nuxt#35791), so this stub matches
178253 // what those users run. The real build is covered by the `vue-3 (no Options API)` e2e variant.
0 commit comments