22 * @vitest -environment jsdom
33 */
44
5+ import type * as SentryCore from '@sentry/core' ;
56import { spanToJSON } from '@sentry/core' ;
67import type { MockInstance } from 'vitest' ;
78import { afterEach , beforeEach , describe , expect , it as baseIt , vi } from 'vitest' ;
@@ -10,6 +11,13 @@ import { createApp, defineAsyncComponent, h, nextTick, ref } from 'vue';
1011import * as Sentry from '../../src' ;
1112import type { Options , TracingOptions } from '../../src/types' ;
1213
14+ vi . mock ( '@sentry/core' , async importOriginal => {
15+ return {
16+ ...( await importOriginal < typeof SentryCore > ( ) ) ,
17+ timestampInSeconds : ( ) => Date . now ( ) / 1000 ,
18+ } ;
19+ } ) ;
20+
1321const PUBLIC_DSN = 'https://username@domain/123' ;
1422const ROOT_SPAN_TIMEOUT_MS = 100 ;
1523
@@ -256,9 +264,8 @@ describe('tracing mixin span creation', () => {
256264 ] ) ;
257265 } ) ;
258266
259- // `maybeEndRootComponentSpan` arms one debounce timer per component, so a late child never
260- // clears the root's earlier timer, and the root's timer ends the span first. The twin test in
261- // the disabled describe below proves the `app.mount()` wrap matches.
267+ // The debounce only waits for recorded render activity, so a child that never mounts records
268+ // none and cannot keep the root render span open.
262269 it ( 'ends the root render span before a deferred child mounts' , ( { uiSpans, initSentry } ) => {
263270 const { app } = createAppWithDeferredChild ( ) ;
264271 initSentry ( { sdk : { app } } ) ;
@@ -271,6 +278,67 @@ describe('tracing mixin span creation', () => {
271278 ] ) ;
272279 } ) ;
273280
281+ it ( 'extends the root render span while children keep mounting within the timeout' , async ( {
282+ uiSpans,
283+ initSentry,
284+ } ) => {
285+ const showChild = ref ( false ) ;
286+ const child = { name : 'ChildComponent' , render : ( ) => h ( 'p' , 'child' ) } ;
287+ const app = createApp ( { name : 'RootComponent' , render : ( ) => h ( 'div' , [ showChild . value ? h ( child ) : null ] ) } ) ;
288+ initSentry ( { sdk : { app } } ) ;
289+ const container = document . createElement ( 'div' ) ;
290+
291+ await Sentry . startSpan ( { name : 'pageload' } , async ( ) => {
292+ app . mount ( container ) ;
293+
294+ vi . advanceTimersByTime ( ROOT_SPAN_TIMEOUT_MS - 1 ) ;
295+ showChild . value = true ;
296+ await nextTick ( ) ;
297+
298+ // The original deadline passes, but the child's mount pushed it out.
299+ vi . advanceTimersByTime ( 2 ) ;
300+ expect ( uiSpans . map ( span => span . op ) ) . not . toContain ( UI_RENDER_SPAN_OP ) ;
301+
302+ vi . advanceTimersByTime ( ROOT_SPAN_TIMEOUT_MS + 1 ) ;
303+ } ) ;
304+
305+ expect ( uiSpans ) . toEqual ( [
306+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } ,
307+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
308+ ] ) ;
309+ } ) ;
310+
311+ it ( 'records UI spans when span streaming is enabled' , ( { app, uiSpans, initSentry } ) => {
312+ initSentry ( { tracing : { trackComponents : [ 'ChildComponent' ] } , sdk : { traceLifecycle : 'stream' } } ) ;
313+
314+ mountUnderActiveSpan ( app ) ;
315+
316+ // v10 does not have low-cardinality `ui` span names (only in v11)
317+ expect ( uiSpans ) . toEqual ( [
318+ { name : 'Vue <ChildComponent>' , op : UI_MOUNT_SPAN_OP } ,
319+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } ,
320+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
321+ ] ) ;
322+ } ) ;
323+
324+ it ( 'records UI spans for anonymous components when span streaming is enabled' , ( { uiSpans, initSentry } ) => {
325+ const anonymousChild = { render : ( ) => h ( 'p' , 'child' ) } ;
326+ const app = createApp ( { render : ( ) => h ( 'div' , [ h ( anonymousChild ) ] ) } ) ;
327+ initSentry ( {
328+ tracing : { trackComponents : true } ,
329+ sdk : { app, traceLifecycle : 'stream' } ,
330+ } ) ;
331+
332+ mountUnderActiveSpan ( app ) ;
333+
334+ // v10 does not have low-cardinality `ui` span names (only in v11)
335+ expect ( uiSpans ) . toEqual ( [
336+ { name : 'Vue <Anonymous>' , op : UI_MOUNT_SPAN_OP } ,
337+ { name : 'Vue <Root>' , op : UI_MOUNT_SPAN_OP } ,
338+ { name : 'Application Render' , op : UI_RENDER_SPAN_OP } ,
339+ ] ) ;
340+ } ) ;
341+
274342 // Vue 3 compiles `app.mixin()` down to a no-op returning the app when the `__VUE_OPTIONS_API__`
275343 // build flag is `false`. Nuxt 5 sets that flag by default (nuxt/nuxt#35791), so this stub matches
276344 // what those users run. The real build is covered by the `vue-3 (no Options API)` e2e variant.
@@ -311,7 +379,8 @@ describe('tracing mixin span creation', () => {
311379 expect ( rootInstance . $el ) . toBe ( container . firstElementChild ) ;
312380 } ) ;
313381
314- // Matches the mixin-path twin above: the mixin never waited for late children either.
382+ // Unlike the mixin path, the `app.mount()` wrap only observes the root, so a late child can
383+ // never extend the root render span here.
315384 it ( 'ends the root render span before a deferred child mounts' , ( { uiSpans, initSentry } ) => {
316385 const { app } = createAppWithDeferredChild ( ) ;
317386 disableOptionsApi ( app ) ;
0 commit comments