Skip to content

Commit d7c60b6

Browse files
committed
Merge remote-tracking branch 'origin/develop' into prepare-release/10.22.0
2 parents ed39e05 + fd26569 commit d7c60b6

File tree

5 files changed

+381
-114
lines changed

5 files changed

+381
-114
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,124 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes',
377377
expect(backNavigationEvent.transaction).toBe('/');
378378
expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation');
379379
});
380+
381+
test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({
382+
page,
383+
}) => {
384+
const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
385+
return (
386+
!!transactionEvent?.transaction &&
387+
transactionEvent.contexts?.trace?.op === 'pageload' &&
388+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
389+
);
390+
});
391+
392+
// Set up the page to simulate document.hidden before navigation
393+
await page.addInitScript(() => {
394+
// Wait a bit for Sentry to initialize and start the pageload span
395+
setTimeout(() => {
396+
// Override document.hidden to simulate tab switching
397+
Object.defineProperty(document, 'hidden', {
398+
configurable: true,
399+
get: function () {
400+
return true;
401+
},
402+
});
403+
404+
// Dispatch visibilitychange event to trigger the idle span cancellation logic
405+
document.dispatchEvent(new Event('visibilitychange'));
406+
}, 100); // Small delay to ensure the span has started
407+
});
408+
409+
// Navigate to the lazy route URL
410+
await page.goto('/lazy/inner/1/2/3');
411+
412+
const event = await transactionPromise;
413+
414+
// Verify the lazy route content eventually loads (even though span was cancelled early)
415+
const lazyRouteContent = page.locator('id=innermost-lazy-route');
416+
await expect(lazyRouteContent).toBeVisible();
417+
418+
// Validate that the transaction event has the correct parameterized route name
419+
// even though the span was cancelled early due to document.hidden
420+
expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
421+
expect(event.type).toBe('transaction');
422+
expect(event.contexts?.trace?.op).toBe('pageload');
423+
424+
// Check if the span was indeed cancelled (should have idle_span_finish_reason attribute)
425+
const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason'];
426+
if (idleSpanFinishReason) {
427+
// If the span was cancelled due to visibility change, verify it still got the right name
428+
expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason);
429+
}
430+
});
431+
432+
test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({
433+
page,
434+
}) => {
435+
// First go to home page
436+
await page.goto('/');
437+
438+
const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
439+
return (
440+
!!transactionEvent?.transaction &&
441+
transactionEvent.contexts?.trace?.op === 'navigation' &&
442+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
443+
);
444+
});
445+
446+
// Set up a listener to simulate document.hidden after clicking the navigation link
447+
await page.evaluate(() => {
448+
// Override document.hidden to simulate tab switching
449+
let hiddenValue = false;
450+
Object.defineProperty(document, 'hidden', {
451+
configurable: true,
452+
get: function () {
453+
return hiddenValue;
454+
},
455+
});
456+
457+
// Listen for clicks on the navigation link and simulate document.hidden shortly after
458+
document.addEventListener(
459+
'click',
460+
() => {
461+
setTimeout(() => {
462+
hiddenValue = true;
463+
// Dispatch visibilitychange event to trigger the idle span cancellation logic
464+
document.dispatchEvent(new Event('visibilitychange'));
465+
}, 50); // Small delay to ensure the navigation span has started
466+
},
467+
{ once: true },
468+
);
469+
});
470+
471+
// Click the navigation link to navigate to the lazy route
472+
const navigationLink = page.locator('id=navigation');
473+
await expect(navigationLink).toBeVisible();
474+
await navigationLink.click();
475+
476+
const event = await navigationPromise;
477+
478+
// Verify the lazy route content eventually loads (even though span was cancelled early)
479+
const lazyRouteContent = page.locator('id=innermost-lazy-route');
480+
await expect(lazyRouteContent).toBeVisible();
481+
482+
// Validate that the transaction event has the correct parameterized route name
483+
// even though the span was cancelled early due to document.hidden
484+
expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
485+
expect(event.type).toBe('transaction');
486+
expect(event.contexts?.trace?.op).toBe('navigation');
487+
488+
// Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason)
489+
const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason'];
490+
const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason'];
491+
492+
// Verify that the span was cancelled due to document.hidden
493+
if (cancellationReason) {
494+
expect(cancellationReason).toBe('document.hidden');
495+
}
496+
497+
if (idleSpanFinishReason) {
498+
expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason);
499+
}
500+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { getTraceMetaTags } from '@sentry/core';
2+
import { PassThrough } from 'stream';
3+
import { beforeEach, describe, expect, test, vi } from 'vitest';
4+
import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer';
5+
6+
vi.mock('@opentelemetry/core', () => ({
7+
RPCType: { HTTP: 'http' },
8+
getRPCMetadata: vi.fn(),
9+
}));
10+
11+
vi.mock('@sentry/core', () => ({
12+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source',
13+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin',
14+
getActiveSpan: vi.fn(),
15+
getRootSpan: vi.fn(),
16+
getTraceMetaTags: vi.fn(),
17+
}));
18+
19+
describe('getMetaTagTransformer', () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
(getTraceMetaTags as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
23+
'<meta name="sentry-trace" content="test-trace-id">',
24+
);
25+
});
26+
27+
test('should inject meta tags before closing head tag', () =>
28+
new Promise<void>((resolve, reject) => {
29+
const bodyStream = new PassThrough();
30+
const transformer = getMetaTagTransformer(bodyStream);
31+
32+
let outputData = '';
33+
bodyStream.on('data', chunk => {
34+
outputData += chunk.toString();
35+
});
36+
37+
bodyStream.on('end', () => {
38+
try {
39+
expect(outputData).toContain('<meta name="sentry-trace" content="test-trace-id"></head>');
40+
expect(outputData).not.toContain('</head></head>');
41+
expect(getTraceMetaTags).toHaveBeenCalledTimes(1);
42+
resolve();
43+
} catch (e) {
44+
reject(e);
45+
}
46+
});
47+
48+
transformer.write('<html><head></head><body>Test</body></html>');
49+
transformer.end();
50+
}));
51+
52+
test('should not modify chunks without head closing tag', () =>
53+
new Promise<void>((resolve, reject) => {
54+
const bodyStream = new PassThrough();
55+
const transformer = getMetaTagTransformer(bodyStream);
56+
57+
let outputData = '';
58+
bodyStream.on('data', chunk => {
59+
outputData += chunk.toString();
60+
});
61+
62+
bodyStream.on('end', () => {
63+
try {
64+
expect(outputData).toBe('<html><body>Test</body></html>');
65+
expect(outputData).not.toContain('sentry-trace');
66+
expect(getTraceMetaTags).not.toHaveBeenCalled();
67+
resolve();
68+
} catch (e) {
69+
reject(e);
70+
}
71+
});
72+
73+
transformer.write('<html><body>Test</body></html>');
74+
transformer.end();
75+
}));
76+
77+
test('should handle buffer input', () =>
78+
new Promise<void>((resolve, reject) => {
79+
const bodyStream = new PassThrough();
80+
const transformer = getMetaTagTransformer(bodyStream);
81+
82+
let outputData = '';
83+
bodyStream.on('data', chunk => {
84+
outputData += chunk.toString();
85+
});
86+
87+
bodyStream.on('end', () => {
88+
try {
89+
expect(outputData).toContain('<meta name="sentry-trace" content="test-trace-id"></head>');
90+
expect(getTraceMetaTags).toHaveBeenCalledTimes(1);
91+
resolve();
92+
} catch (e) {
93+
reject(e);
94+
}
95+
});
96+
97+
transformer.write(Buffer.from('<html><head></head><body>Test</body></html>'));
98+
transformer.end();
99+
}));
100+
101+
test('should handle multiple chunks', () =>
102+
new Promise<void>((resolve, reject) => {
103+
const bodyStream = new PassThrough();
104+
const transformer = getMetaTagTransformer(bodyStream);
105+
106+
let outputData = '';
107+
bodyStream.on('data', chunk => {
108+
outputData += chunk.toString();
109+
});
110+
111+
bodyStream.on('end', () => {
112+
try {
113+
expect(outputData).toContain('<meta name="sentry-trace" content="test-trace-id"></head>');
114+
expect(outputData).toContain('<body>Test content</body>');
115+
expect(getTraceMetaTags).toHaveBeenCalledTimes(1);
116+
resolve();
117+
} catch (e) {
118+
reject(e);
119+
}
120+
});
121+
122+
transformer.write('<html><head>');
123+
transformer.write('</head><body>Test content</body>');
124+
transformer.write('</html>');
125+
transformer.end();
126+
}));
127+
});

packages/react-router/test/server/getMetaTagTransformer.ts

Lines changed: 0 additions & 91 deletions
This file was deleted.

0 commit comments

Comments
 (0)