From c9a0eda94c83fd5e0f89a97f28289c4ae9798acd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 3 Mar 2026 13:41:49 +0100 Subject: [PATCH 1/4] re-use bundler hook --- .../loaders/componentAnnotationLoader.ts | 45 ++++++ packages/nextjs/src/config/loaders/index.ts | 1 + .../turbopack/constructTurbopackConfig.ts | 21 +++ packages/nextjs/src/config/types.ts | 10 ++ .../loaders/componentAnnotationLoader.test.ts | 137 ++++++++++++++++++ 5 files changed, 214 insertions(+) create mode 100644 packages/nextjs/src/config/loaders/componentAnnotationLoader.ts create mode 100644 packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts diff --git a/packages/nextjs/src/config/loaders/componentAnnotationLoader.ts b/packages/nextjs/src/config/loaders/componentAnnotationLoader.ts new file mode 100644 index 000000000000..b2b943302419 --- /dev/null +++ b/packages/nextjs/src/config/loaders/componentAnnotationLoader.ts @@ -0,0 +1,45 @@ +import { createComponentNameAnnotateHooks } from '@sentry/bundler-plugin-core'; +import type { LoaderThis } from './types'; + +export type ComponentAnnotationLoaderOptions = { + ignoredComponents?: string[]; +}; + +/** + * Turbopack loader that annotates React components with `data-sentry-component`, + * `data-sentry-element`, and `data-sentry-source-file` attributes. + * + * This is the Turbopack equivalent of what `@sentry/webpack-plugin` does + * via the `reactComponentAnnotation` option and `@sentry/babel-plugin-component-annotate`. + * + * Options: + * - `ignoredComponents`: List of component names to exclude from annotation. + */ +export default function componentAnnotationLoader( + this: LoaderThis, + userCode: string, +): void { + const options = 'getOptions' in this ? this.getOptions() : this.query; + const ignoredComponents = options.ignoredComponents ?? []; + + // We do not want to cache results across builds + this.cacheable(false); + + const callback = this.async() ?? this.callback; + + const hooks = createComponentNameAnnotateHooks(ignoredComponents, false); + + hooks + .transform(userCode, this.resourcePath) + .then(result => { + if (result) { + callback(null, result.code, result.map); + } else { + callback(null, userCode); + } + }) + .catch(() => { + // On error, pass through the original code gracefully + callback(null, userCode); + }); +} diff --git a/packages/nextjs/src/config/loaders/index.ts b/packages/nextjs/src/config/loaders/index.ts index 359d72d7def6..0ddd354f10fb 100644 --- a/packages/nextjs/src/config/loaders/index.ts +++ b/packages/nextjs/src/config/loaders/index.ts @@ -2,3 +2,4 @@ export { default as valueInjectionLoader } from './valueInjectionLoader'; export { default as prefixLoader } from './prefixLoader'; export { default as wrappingLoader } from './wrappingLoader'; export { default as moduleMetadataInjectionLoader } from './moduleMetadataInjectionLoader'; +export { default as componentAnnotationLoader } from './componentAnnotationLoader'; diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index d8f70efbacf1..b6d963fc423e 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -79,6 +79,27 @@ export function constructTurbopackConfig({ }); } + // Add component annotation loader for react component name annotation in Turbopack builds. + // This is only added when turbopackReactComponentAnnotation.enabled is set AND the Next.js + // version supports the `condition` field in Turbopack rules (Next.js 16+). + const turbopackReactComponentAnnotation = userSentryOptions?._experimental?.turbopackReactComponentAnnotation; + if (turbopackReactComponentAnnotation?.enabled && nextJsVersion && supportsTurbopackRuleCondition(nextJsVersion)) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { + matcher: '*.{tsx,jsx}', + rule: { + condition: { not: 'foreign' }, + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'componentAnnotationLoader.js'), + options: { + ignoredComponents: turbopackReactComponentAnnotation.ignoredComponents ?? [], + }, + }, + ], + }, + }); + } + return newConfig; } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 233860fb1388..c79dad7e694e 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -724,6 +724,16 @@ export type SentryBuildOptions = { * Requires Next.js 16+ */ turbopackApplicationKey?: string; + /** + * Options for React component name annotation in Turbopack builds. + * When enabled, JSX elements are annotated with `data-sentry-component`, + * `data-sentry-element`, and `data-sentry-source-file` attributes. + * Requires Next.js 16+. + */ + turbopackReactComponentAnnotation?: { + enabled?: boolean; + ignoredComponents?: string[]; + }; }>; /** diff --git a/packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts b/packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts new file mode 100644 index 000000000000..f12a49f8e24a --- /dev/null +++ b/packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ComponentAnnotationLoaderOptions } from '../../../src/config/loaders/componentAnnotationLoader'; +import componentAnnotationLoader from '../../../src/config/loaders/componentAnnotationLoader'; +import type { LoaderThis } from '../../../src/config/loaders/types'; + +const { mockTransform, mockCreateHooks } = vi.hoisted(() => { + const mockTransform = vi.fn(); + const mockCreateHooks = vi.fn().mockReturnValue({ transform: mockTransform }); + return { mockTransform, mockCreateHooks }; +}); + +vi.mock('@sentry/bundler-plugin-core', () => ({ + createComponentNameAnnotateHooks: mockCreateHooks, +})); + +function createMockLoaderContext( + options: ComponentAnnotationLoaderOptions = {}, + resourcePath = '/app/components/Button.tsx', +): LoaderThis & { callback: ReturnType } { + const callback = vi.fn(); + return { + resourcePath, + addDependency: vi.fn(), + cacheable: vi.fn(), + async: vi.fn().mockReturnValue(callback), + callback, + getOptions: vi.fn().mockReturnValue(options), + }; +} + +describe('componentAnnotationLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTransform.mockReset(); + mockCreateHooks.mockReturnValue({ transform: mockTransform }); + }); + + it('calls this.async() and uses callback with transformed code and source map', async () => { + const mockResult = { + code: 'transformed code', + map: { version: 3, sources: ['Button.tsx'] }, + }; + mockTransform.mockResolvedValue(mockResult); + + const ctx = createMockLoaderContext(); + componentAnnotationLoader.call(ctx, 'original code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(ctx.async).toHaveBeenCalled(); + expect(ctx.callback).toHaveBeenCalledWith(null, 'transformed code', { version: 3, sources: ['Button.tsx'] }); + }); + + it('passes through original code when transform returns null', async () => { + mockTransform.mockResolvedValue(null); + + const ctx = createMockLoaderContext(); + componentAnnotationLoader.call(ctx, 'original code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(ctx.callback).toHaveBeenCalledWith(null, 'original code'); + }); + + it('passes through original code on transform error', async () => { + mockTransform.mockRejectedValue(new Error('babel error')); + + const ctx = createMockLoaderContext(); + componentAnnotationLoader.call(ctx, 'original code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(ctx.callback).toHaveBeenCalledWith(null, 'original code'); + }); + + it('sets cacheable(false)', () => { + mockTransform.mockResolvedValue(null); + + const ctx = createMockLoaderContext(); + componentAnnotationLoader.call(ctx, 'original code'); + + expect(ctx.cacheable).toHaveBeenCalledWith(false); + }); + + it('reads options via getOptions() (webpack 5)', async () => { + mockTransform.mockResolvedValue(null); + + const ctx = createMockLoaderContext({ ignoredComponents: ['Header'] }); + componentAnnotationLoader.call(ctx, 'original code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockCreateHooks).toHaveBeenCalledWith(['Header'], false); + }); + + it('reads options via this.query (webpack 4)', async () => { + mockTransform.mockResolvedValue(null); + + const callback = vi.fn(); + const ctx = { + resourcePath: '/app/components/Button.tsx', + addDependency: vi.fn(), + cacheable: vi.fn(), + async: vi.fn().mockReturnValue(callback), + callback, + query: { ignoredComponents: ['Footer'] }, + } as unknown as LoaderThis; + + componentAnnotationLoader.call(ctx, 'original code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockCreateHooks).toHaveBeenCalledWith(['Footer'], false); + }); + + it('defaults ignoredComponents to empty array', async () => { + mockTransform.mockResolvedValue(null); + + const ctx = createMockLoaderContext({}); + componentAnnotationLoader.call(ctx, 'original code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockCreateHooks).toHaveBeenCalledWith([], false); + }); + + it('passes resourcePath to transform', async () => { + mockTransform.mockResolvedValue(null); + + const ctx = createMockLoaderContext({}, '/app/pages/Home.tsx'); + componentAnnotationLoader.call(ctx, 'some code'); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockTransform).toHaveBeenCalledWith('some code', '/app/pages/Home.tsx'); + }); +}); From dd8aaa003caf3d03e48acf24ea9a966b20322af7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 3 Mar 2026 13:41:55 +0100 Subject: [PATCH 2/4] tests --- .../app/component-annotation/page.tsx | 18 ++ .../nextjs-16/next.config.ts | 3 + .../tests/component-annotation.test.ts | 36 ++++ .../constructTurbopackConfig.test.ts | 179 ++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx new file mode 100644 index 000000000000..8ac6973dc5c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function ComponentAnnotationTestPage() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 342ba13b1206..41814b8152d0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -11,5 +11,8 @@ export default withSentryConfig(nextConfig, { _experimental: { vercelCronsMonitoring: true, turbopackApplicationKey: 'nextjs-16-e2e', + turbopackReactComponentAnnotation: { + enabled: true, + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts new file mode 100644 index 000000000000..76f979b38484 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const isWebpackDev = process.env.TEST_ENV === 'development-webpack'; + +test('React component annotation adds data-sentry-component attributes (Turbopack)', async ({ page }) => { + test.skip(isWebpackDev, 'Only relevant for Turbopack builds'); + + await page.goto('/component-annotation'); + + const button = page.locator('#annotated-btn'); + await expect(button).toBeVisible(); + + // Set up error listener before clicking + const errorPromise = waitForError('nextjs-16', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'component-annotation-test') ?? false; + }); + + await button.click(); + const errorEvent = await errorPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('component-annotation-test'); + + // In production, TEST_ENV=production is shared by both turbopack and webpack variants. + // The component annotation loader only runs in Turbopack builds, so only assert + // DOM attributes and breadcrumb component names when the build is actually turbopack. + const annotatedEl = page.locator('[data-sentry-component="ComponentAnnotationTestPage"]'); + const isAnnotated = (await annotatedEl.count()) > 0; + + if (isAnnotated) { + await expect(annotatedEl).toBeVisible(); + + const clickBreadcrumb = errorEvent.breadcrumbs?.find(bc => bc.category === 'ui.click'); + expect(clickBreadcrumb?.data?.['ui.component_name']).toBe('ComponentAnnotationTestPage'); + } +}); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index d1bf313d16f2..dacd0bd3857b 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -17,6 +17,9 @@ vi.mock('path', async () => { if (lastArg === 'moduleMetadataInjectionLoader.js') { return '/mocked/path/to/moduleMetadataInjectionLoader.js'; } + if (lastArg === 'componentAnnotationLoader.js') { + return '/mocked/path/to/componentAnnotationLoader.js'; + } return '/mocked/path/to/valueInjectionLoader.js'; }), }; @@ -1080,6 +1083,182 @@ describe('moduleMetadataInjection with applicationKey', () => { }); }); +describe('componentAnnotation with turbopackReactComponentAnnotation', () => { + it('should add component annotation loader rule when enabled and Next.js >= 16', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockImplementation((...args: string[]) => { + const lastArg = args[args.length - 1]; + if (lastArg === 'componentAnnotationLoader.js') { + return '/mocked/path/to/componentAnnotationLoader.js'; + } + if (lastArg === 'moduleMetadataInjectionLoader.js') { + return '/mocked/path/to/moduleMetadataInjectionLoader.js'; + } + return '/mocked/path/to/valueInjectionLoader.js'; + }); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { + _experimental: { + turbopackReactComponentAnnotation: { enabled: true }, + }, + }, + nextJsVersion: '16.0.0', + }); + + expect(result.rules!['*.{tsx,jsx}']).toEqual({ + condition: { not: 'foreign' }, + loaders: [ + { + loader: '/mocked/path/to/componentAnnotationLoader.js', + options: { + ignoredComponents: [], + }, + }, + ], + }); + }); + + it('should NOT add component annotation rule when enabled is false', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { + _experimental: { + turbopackReactComponentAnnotation: { enabled: false }, + }, + }, + nextJsVersion: '16.0.0', + }); + + expect(result.rules!['*.{tsx,jsx}']).toBeUndefined(); + }); + + it('should NOT add component annotation rule when not set', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: {}, + nextJsVersion: '16.0.0', + }); + + expect(result.rules!['*.{tsx,jsx}']).toBeUndefined(); + }); + + it('should NOT add component annotation rule when Next.js < 16', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { + _experimental: { + turbopackReactComponentAnnotation: { enabled: true }, + }, + }, + nextJsVersion: '15.4.1', + }); + + expect(result.rules!['*.{tsx,jsx}']).toBeUndefined(); + }); + + it('should NOT add component annotation rule when nextJsVersion is undefined', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { + _experimental: { + turbopackReactComponentAnnotation: { enabled: true }, + }, + }, + nextJsVersion: undefined, + }); + + expect(result.rules!['*.{tsx,jsx}']).toBeUndefined(); + }); + + it('should pass ignoredComponents to loader options', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockImplementation((...args: string[]) => { + const lastArg = args[args.length - 1]; + if (lastArg === 'componentAnnotationLoader.js') { + return '/mocked/path/to/componentAnnotationLoader.js'; + } + if (lastArg === 'moduleMetadataInjectionLoader.js') { + return '/mocked/path/to/moduleMetadataInjectionLoader.js'; + } + return '/mocked/path/to/valueInjectionLoader.js'; + }); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { + _experimental: { + turbopackReactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Header', 'Footer'], + }, + }, + }, + nextJsVersion: '16.0.0', + }); + + const rule = result.rules!['*.{tsx,jsx}'] as { + condition: unknown; + loaders: Array<{ loader: string; options: { ignoredComponents: string[] } }>; + }; + expect(rule.loaders[0]!.options.ignoredComponents).toEqual(['Header', 'Footer']); + }); + + it('should coexist with value injection and module metadata rules', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockImplementation((...args: string[]) => { + const lastArg = args[args.length - 1]; + if (lastArg === 'componentAnnotationLoader.js') { + return '/mocked/path/to/componentAnnotationLoader.js'; + } + if (lastArg === 'moduleMetadataInjectionLoader.js') { + return '/mocked/path/to/moduleMetadataInjectionLoader.js'; + } + return '/mocked/path/to/valueInjectionLoader.js'; + }); + + const userNextConfig: NextConfigObject = {}; + const mockRouteManifest: RouteManifest = { + dynamicRoutes: [], + staticRoutes: [{ path: '/', regex: '/' }], + isrRoutes: [], + }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: { + _experimental: { + turbopackApplicationKey: 'my-app', + turbopackReactComponentAnnotation: { enabled: true }, + }, + }, + routeManifest: mockRouteManifest, + nextJsVersion: '16.0.0', + }); + + // Value injection rules should be present + expect(result.rules!['**/instrumentation-client.*']).toBeDefined(); + expect(result.rules!['**/instrumentation.*']).toBeDefined(); + // Module metadata loader should be present + expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeDefined(); + // Component annotation loader should be present + expect(result.rules!['*.{tsx,jsx}']).toBeDefined(); + }); +}); + describe('safelyAddTurbopackRule', () => { const mockRule = { loaders: [ From a0b24026441059c87f47b1a19f1983a209d3fdf8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 3 Mar 2026 13:57:49 +0100 Subject: [PATCH 3/4] add changelog entry --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a40cb69762..755f0b64d220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ ## Unreleased +### Important Changes + +- **feat(nextjs): Add Turbopack support for React component name annotation ([#19XXX](https://github.com/getsentry/sentry-javascript/pull/19XXX))** + + We added experimental support for React component name annotation in Turbopack builds. When enabled, JSX elements + are annotated with `data-sentry-component`, `data-sentry-element`, and `data-sentry-source-file` attributes at build + time. This enables searching Replays by component name, seeing component names in breadcrumbs, and performance + monitoring — previously only available with webpack builds. + + This feature requires Next.js 16+ and is currently behind an experimental flag: + + ```js + // next.config.ts + import { withSentryConfig } from '@sentry/nextjs'; + + export default withSentryConfig(nextConfig, { + _experimental: { + turbopackReactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Header', 'Footer'], // optional + }, + }, + }); + ``` + +### Other Changes + - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott ## 10.41.0 From 17283bdcab776b2b5fc6bcbe8ff22ba0bfb43453 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 3 Mar 2026 14:59:30 +0100 Subject: [PATCH 4/4] fix test --- .../nextjs-16/tests/component-annotation.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts index 76f979b38484..02e3a006bfdc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts @@ -22,12 +22,11 @@ test('React component annotation adds data-sentry-component attributes (Turbopac expect(errorEvent.exception?.values?.[0]?.value).toBe('component-annotation-test'); // In production, TEST_ENV=production is shared by both turbopack and webpack variants. - // The component annotation loader only runs in Turbopack builds, so only assert - // DOM attributes and breadcrumb component names when the build is actually turbopack. - const annotatedEl = page.locator('[data-sentry-component="ComponentAnnotationTestPage"]'); - const isAnnotated = (await annotatedEl.count()) > 0; - - if (isAnnotated) { + // The component annotation loader only runs in Turbopack builds, so use the independent + // turbopack tag (set by the SDK based on build metadata) to gate assertions rather than + // checking the feature's own output, which would silently pass on regression. + if (errorEvent.tags?.turbopack) { + const annotatedEl = page.locator('[data-sentry-component="ComponentAnnotationTestPage"]'); await expect(annotatedEl).toBeVisible(); const clickBreadcrumb = errorEvent.breadcrumbs?.find(bc => bc.category === 'ui.click');