From a648434ecf3ea424885083a7217541b9b91169bb Mon Sep 17 00:00:00 2001 From: igz0 <37741728+igz0@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:08:15 +0900 Subject: [PATCH] fix(nextjs): preserve directive prologues in turbopack loaders --- .../loaders/moduleMetadataInjectionLoader.ts | 7 +- .../config/loaders/valueInjectionLoader.ts | 154 ++++++++++++++++-- .../moduleMetadataInjectionLoader.test.ts | 26 +++ .../test/config/valueInjectionLoader.test.ts | 73 ++++++++- 4 files changed, 243 insertions(+), 17 deletions(-) diff --git a/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts b/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts index 96c00569e06f..b26eb452e13b 100644 --- a/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts @@ -1,5 +1,5 @@ import type { LoaderThis } from './types'; -import { SKIP_COMMENT_AND_DIRECTIVE_REGEX } from './valueInjectionLoader'; +import { findInjectionIndexAfterDirectives } from './valueInjectionLoader'; export type ModuleMetadataInjectionLoaderOptions = { applicationKey: string; @@ -39,7 +39,6 @@ export default function moduleMetadataInjectionLoader( `e._sentryModuleMetadata[(new e.Error).stack]=Object.assign({},e._sentryModuleMetadata[(new e.Error).stack],${metadata});` + '}catch(e){}}();'; - return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => { - return match + injectedCode; - }); + const injectionIndex = findInjectionIndexAfterDirectives(userCode); + return `${userCode.slice(0, injectionIndex)}${injectedCode}${userCode.slice(injectionIndex)}`; } diff --git a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts index 62cabcf818b8..e7c19c30fc68 100644 --- a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts +++ b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts @@ -1,18 +1,149 @@ -// Rollup doesn't like if we put the directive regex as a literal (?). No idea why. -/* oxlint-disable sdk/no-regexp-constructor */ - import type { LoaderThis } from './types'; export type ValueInjectionLoaderOptions = { values: Record; }; -// We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other directive. -// As an additional complication directives may come after any number of comments. -// This regex is shamelessly stolen from: https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/7f984482c73e4284e8b12a08dfedf23b5a82f0af/packages/bundler-plugin-core/src/index.ts#L535-L539 -export const SKIP_COMMENT_AND_DIRECTIVE_REGEX = - // Note: CodeQL complains that this regex potentially has n^2 runtime. This likely won't affect realistic files. - new RegExp('^(?:\\s*|/\\*(?:.|\\r|\\n)*?\\*/|//.*[\\n\\r])*(?:"[^"]*";?|\'[^\']*\';?)?'); +// We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other +// directives. A small scanner is easier to reason about than the previous regex and avoids regex backtracking concerns. +export function findInjectionIndexAfterDirectives(userCode: string): number { + let index = 0; + let lastDirectiveEndIndex: number | undefined; + + while (true) { + const statementStartIndex = skipWhitespaceAndComments(userCode, index); + + const nextDirectiveIndex = skipDirective(userCode, statementStartIndex); + if (nextDirectiveIndex === undefined) { + return lastDirectiveEndIndex ?? statementStartIndex; + } + + const statementEndIndex = skipDirectiveTerminator(userCode, nextDirectiveIndex); + if (statementEndIndex === undefined) { + return lastDirectiveEndIndex ?? statementStartIndex; + } + + index = statementEndIndex; + lastDirectiveEndIndex = statementEndIndex; + } +} + +function skipWhitespaceAndComments(userCode: string, startIndex: number): number { + let index = startIndex; + + while (index < userCode.length) { + const char = userCode[index]; + const nextChar = userCode[index + 1]; + + if (char && /\s/.test(char)) { + index += 1; + continue; + } + + if (char === '/' && nextChar === '/') { + index += 2; + while (index < userCode.length && userCode[index] !== '\n' && userCode[index] !== '\r') { + index += 1; + } + continue; + } + + if (char === '/' && nextChar === '*') { + const commentEndIndex = userCode.indexOf('*/', index + 2); + if (commentEndIndex === -1) { + return startIndex; + } + + index = commentEndIndex + 2; + continue; + } + + return index; + } + + return index; +} + +function skipDirective(userCode: string, startIndex: number): number | undefined { + const quote = userCode[startIndex]; + + if (quote !== '"' && quote !== "'") { + return undefined; + } + + let index = startIndex + 1; + + while (index < userCode.length) { + const char = userCode[index]; + + if (char === '\\') { + index += 2; + continue; + } + + if (char === quote) { + index += 1; + break; + } + + if (char === '\n' || char === '\r') { + return undefined; + } + + index += 1; + } + + if (index > userCode.length || userCode[index - 1] !== quote) { + return undefined; + } + + return index; +} + +function skipDirectiveTerminator(userCode: string, startIndex: number): number | undefined { + let index = startIndex; + + while (index < userCode.length) { + const char = userCode[index]; + const nextChar = userCode[index + 1]; + + if (char === ';') { + return index + 1; + } + + if (char === '\n' || char === '\r' || char === '}') { + return index; + } + + if (char && /\s/.test(char)) { + index += 1; + continue; + } + + if (char === '/' && nextChar === '/') { + return index; + } + + if (char === '/' && nextChar === '*') { + const commentEndIndex = userCode.indexOf('*/', index + 2); + if (commentEndIndex === -1) { + return undefined; + } + + const comment = userCode.slice(index + 2, commentEndIndex); + if (comment.includes('\n') || comment.includes('\r')) { + return index; + } + + index = commentEndIndex + 2; + continue; + } + + return undefined; + } + + return index; +} /** * Set values on the global/window object at the start of a module. @@ -36,7 +167,6 @@ export default function valueInjectionLoader(this: LoaderThis `globalThis["${key}"] = ${JSON.stringify(value)};`) .join(''); - return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => { - return match + injectedCode; - }); + const injectionIndex = findInjectionIndexAfterDirectives(userCode); + return `${userCode.slice(0, injectionIndex)}${injectedCode}${userCode.slice(injectionIndex)}`; } diff --git a/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts b/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts index 1a6a2cd14b71..f6c1c613bd00 100644 --- a/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts +++ b/packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts @@ -131,4 +131,30 @@ describe('moduleMetadataInjectionLoader', () => { expect(result).toContain('"_sentryBundlerPluginAppKey:test-key-123":true'); }); + + it('should inject after multiple directives', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '"use strict";\n"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const clientDirectiveIndex = result.indexOf('"use client"'); + const importIndex = result.indexOf("import React from 'react';"); + + expect(metadataIndex).toBeGreaterThan(clientDirectiveIndex); + expect(metadataIndex).toBeLessThan(importIndex); + }); + + it('should inject after comments between multiple directives', () => { + const loaderThis = createLoaderThis('my-app'); + const userCode = '"use strict";\n/* keep */\n"use client";\nimport React from \'react\';'; + + const result = moduleMetadataInjectionLoader.call(loaderThis, userCode); + + const metadataIndex = result.indexOf('_sentryModuleMetadata'); + const clientDirectiveIndex = result.indexOf('"use client"'); + + expect(metadataIndex).toBeGreaterThan(clientDirectiveIndex); + }); }); diff --git a/packages/nextjs/test/config/valueInjectionLoader.test.ts b/packages/nextjs/test/config/valueInjectionLoader.test.ts index 57b40b006baa..cd6c93e31690 100644 --- a/packages/nextjs/test/config/valueInjectionLoader.test.ts +++ b/packages/nextjs/test/config/valueInjectionLoader.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { LoaderThis } from '../../src/config/loaders/types'; import type { ValueInjectionLoaderOptions } from '../../src/config/loaders/valueInjectionLoader'; -import valueInjectionLoader from '../../src/config/loaders/valueInjectionLoader'; +import valueInjectionLoader, { findInjectionIndexAfterDirectives } from '../../src/config/loaders/valueInjectionLoader'; const defaultLoaderThis = { addDependency: () => undefined, @@ -149,4 +149,75 @@ describe.each([[clientConfigLoaderThis], [instrumentationLoaderThis]])('valueInj expect(result).toMatchSnapshot(); expect(result).toMatch(';globalThis["foo"] = "bar";'); }); + + it('should correctly insert values after multiple directives', () => { + const userCode = ` + "use strict"; + "use client"; + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf('"use client"'); + const importIndex = result.indexOf("import * as Sentry from '@sentry/nextjs';"); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + expect(injectionIndex).toBeLessThan(importIndex); + }); + + it('should correctly insert values after comments between multiple directives', () => { + const userCode = ` + "use strict"; + /* keep */ + "use client"; + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf('"use client"'); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + }); + + it('should correctly insert values after semicolon-free directives', () => { + const userCode = ` + "use strict" + "use client" + import * as Sentry from '@sentry/nextjs'; + Sentry.init(); + `; + + const result = valueInjectionLoader.call(loaderThis, userCode); + + const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";'); + const clientDirectiveIndex = result.indexOf('"use client"'); + + expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex); + }); +}); + +describe('findInjectionIndexAfterDirectives', () => { + it('returns the position immediately after the last directive', () => { + const userCode = '"use strict";\n"use client";\nimport React from \'react\';'; + + expect(userCode.slice(findInjectionIndexAfterDirectives(userCode))).toBe("\nimport React from 'react';"); + }); + + it('does not skip a string literal that is not a directive', () => { + const userCode = '"use client" + suffix;'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(0); + }); + + it('treats a block comment without a line break as part of the same statement', () => { + const userCode = '"use client" /* comment */ + suffix;'; + + expect(findInjectionIndexAfterDirectives(userCode)).toBe(0); + }); });