From 96b134e040413beceeaf89622f80e1d3db8d9bbc Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 5 Oct 2026 11:09:49 +0200 Subject: [PATCH] fix(server-utils): Transform modules once when two Sentry Vite plugins add orchestrion `sentryCloudflareVitePlugin` and a framework plugin such as `sentryReactRouter` both add the orchestrion Vite plugin to one build. Both instances then transform each instrumented module, the second one declares the injected snippet again, and the build fails with a redeclaration error. The plugin is now named `sentry-orchestrion`, and an instance that finds an earlier one in the same build leaves the transform to it. `react-router-8-framework` no longer needs `buildTimeInstrumentation: false` in its Cloudflare config. Co-Authored-By: Claude Opus 5.5 --- .../vite.cloudflare.config.ts | 3 +- .../src/orchestrion/bundler/vite.ts | 22 ++++++++++--- .../test/orchestrion/bundler.test.ts | 32 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 8a905ba1e382..002bb3a77fbe 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -10,8 +10,7 @@ export default defineConfig(async config => ({ reactRouter(), sentryCloudflareVitePlugin(), ...((await sentryReactRouter( - // Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build. - { sourcemaps: { disable: true }, buildTimeInstrumentation: false }, + { sourcemaps: { disable: true } }, config, // eslint-disable-next-line @typescript-eslint/no-explicit-any )) as any[]), diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b8f66d961f4..ed43ec307ff6 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -9,6 +9,8 @@ import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './re type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; +const PLUGIN_NAME = 'sentry-orchestrion'; + // On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of // client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without // this gate the transform would also run in the CLIENT build — where modules @@ -16,10 +18,10 @@ type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: // snippet's import of the subscriber factories (which import // `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate // on the `ssr` flag, which Vite passes on both major versions. -function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] { +function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] { const gate = (handler: TransformHandler): TransformHandler => function (code, id, opts) { - if (!opts?.ssr) { + if (!opts?.ssr || isDuplicate()) { return null; } return handler.call(this, code, id, opts); @@ -62,9 +64,13 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { '@sentry/server-utils', ]; - return { + // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. + let isDuplicate = false; + + const plugin: Plugin = { ...upstream, - transform: ssrOnlyTransform(upstream.transform), + name: PLUGIN_NAME, + transform: ssrOnlyTransform(upstream.transform, () => isDuplicate), // The module-injected snippet imports `@sentry/server-utils` from INSIDE // transformed `node_modules` files. Under isolated installs (pnpm) that bare // specifier doesn't resolve from an instrumented package's location, so when @@ -119,8 +125,12 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { resolve: { noExternal: noExternalModules() } }; }, configResolved(config: ResolvedConfig): void { + // Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and + // `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build. + isDuplicate = config.plugins.find(p => p.name === PLUGIN_NAME) !== plugin; + // Nothing is force-bundled in `serve`, so an externalized module is expected there. - if (config.command === 'serve') { + if (isDuplicate || config.command === 'serve') { return; } @@ -140,4 +150,6 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { } }, }; + + return plugin; } diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b04fa7d1f160..e4b3bf6798f7 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -227,6 +227,7 @@ describe('sentryOrchestrionPlugin (vite)', () => { const plugin = vitePlugin(); (plugin.configResolved as (config: unknown) => void)({ command, + plugins: [plugin], ssr: { external: ssrExternal }, logger: { warn }, } as unknown as ResolvedConfig); @@ -324,6 +325,37 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); }); + it('transforms and warns only in the first instance when two Sentry plugins add it to one build', () => { + const first = vitePlugin(); + const second = vitePlugin(); + const warn = vi.fn(); + const config = { + command: 'build', + plugins: [{ name: 'other' }, first, second], + ssr: { external: ['mysql'] }, + logger: { warn }, + } as unknown as ResolvedConfig; + + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstTransform = first.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + const secondTransform = second.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + expect(firstTransform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); + expect(secondTransform.call({}, 'code', 'id', { ssr: true })).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + }); + it('gates resolveId on the ssr flag and falls back to self-resolution', async () => { const plugin = vitePlugin(); const resolveId = plugin.resolveId as (