Skip to content

Commit a648434

Browse files
committed
fix(nextjs): preserve directive prologues in turbopack loaders
1 parent 4be2e67 commit a648434

File tree

4 files changed

+243
-17
lines changed

4 files changed

+243
-17
lines changed

packages/nextjs/src/config/loaders/moduleMetadataInjectionLoader.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { LoaderThis } from './types';
2-
import { SKIP_COMMENT_AND_DIRECTIVE_REGEX } from './valueInjectionLoader';
2+
import { findInjectionIndexAfterDirectives } from './valueInjectionLoader';
33

44
export type ModuleMetadataInjectionLoaderOptions = {
55
applicationKey: string;
@@ -39,7 +39,6 @@ export default function moduleMetadataInjectionLoader(
3939
`e._sentryModuleMetadata[(new e.Error).stack]=Object.assign({},e._sentryModuleMetadata[(new e.Error).stack],${metadata});` +
4040
'}catch(e){}}();';
4141

42-
return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => {
43-
return match + injectedCode;
44-
});
42+
const injectionIndex = findInjectionIndexAfterDirectives(userCode);
43+
return `${userCode.slice(0, injectionIndex)}${injectedCode}${userCode.slice(injectionIndex)}`;
4544
}

packages/nextjs/src/config/loaders/valueInjectionLoader.ts

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,149 @@
1-
// Rollup doesn't like if we put the directive regex as a literal (?). No idea why.
2-
/* oxlint-disable sdk/no-regexp-constructor */
3-
41
import type { LoaderThis } from './types';
52

63
export type ValueInjectionLoaderOptions = {
74
values: Record<string, unknown>;
85
};
96

10-
// We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other directive.
11-
// As an additional complication directives may come after any number of comments.
12-
// 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
13-
export const SKIP_COMMENT_AND_DIRECTIVE_REGEX =
14-
// Note: CodeQL complains that this regex potentially has n^2 runtime. This likely won't affect realistic files.
15-
new RegExp('^(?:\\s*|/\\*(?:.|\\r|\\n)*?\\*/|//.*[\\n\\r])*(?:"[^"]*";?|\'[^\']*\';?)?');
7+
// We need to be careful not to inject anything before any `"use strict";`s or "use client"s or really any other
8+
// directives. A small scanner is easier to reason about than the previous regex and avoids regex backtracking concerns.
9+
export function findInjectionIndexAfterDirectives(userCode: string): number {
10+
let index = 0;
11+
let lastDirectiveEndIndex: number | undefined;
12+
13+
while (true) {
14+
const statementStartIndex = skipWhitespaceAndComments(userCode, index);
15+
16+
const nextDirectiveIndex = skipDirective(userCode, statementStartIndex);
17+
if (nextDirectiveIndex === undefined) {
18+
return lastDirectiveEndIndex ?? statementStartIndex;
19+
}
20+
21+
const statementEndIndex = skipDirectiveTerminator(userCode, nextDirectiveIndex);
22+
if (statementEndIndex === undefined) {
23+
return lastDirectiveEndIndex ?? statementStartIndex;
24+
}
25+
26+
index = statementEndIndex;
27+
lastDirectiveEndIndex = statementEndIndex;
28+
}
29+
}
30+
31+
function skipWhitespaceAndComments(userCode: string, startIndex: number): number {
32+
let index = startIndex;
33+
34+
while (index < userCode.length) {
35+
const char = userCode[index];
36+
const nextChar = userCode[index + 1];
37+
38+
if (char && /\s/.test(char)) {
39+
index += 1;
40+
continue;
41+
}
42+
43+
if (char === '/' && nextChar === '/') {
44+
index += 2;
45+
while (index < userCode.length && userCode[index] !== '\n' && userCode[index] !== '\r') {
46+
index += 1;
47+
}
48+
continue;
49+
}
50+
51+
if (char === '/' && nextChar === '*') {
52+
const commentEndIndex = userCode.indexOf('*/', index + 2);
53+
if (commentEndIndex === -1) {
54+
return startIndex;
55+
}
56+
57+
index = commentEndIndex + 2;
58+
continue;
59+
}
60+
61+
return index;
62+
}
63+
64+
return index;
65+
}
66+
67+
function skipDirective(userCode: string, startIndex: number): number | undefined {
68+
const quote = userCode[startIndex];
69+
70+
if (quote !== '"' && quote !== "'") {
71+
return undefined;
72+
}
73+
74+
let index = startIndex + 1;
75+
76+
while (index < userCode.length) {
77+
const char = userCode[index];
78+
79+
if (char === '\\') {
80+
index += 2;
81+
continue;
82+
}
83+
84+
if (char === quote) {
85+
index += 1;
86+
break;
87+
}
88+
89+
if (char === '\n' || char === '\r') {
90+
return undefined;
91+
}
92+
93+
index += 1;
94+
}
95+
96+
if (index > userCode.length || userCode[index - 1] !== quote) {
97+
return undefined;
98+
}
99+
100+
return index;
101+
}
102+
103+
function skipDirectiveTerminator(userCode: string, startIndex: number): number | undefined {
104+
let index = startIndex;
105+
106+
while (index < userCode.length) {
107+
const char = userCode[index];
108+
const nextChar = userCode[index + 1];
109+
110+
if (char === ';') {
111+
return index + 1;
112+
}
113+
114+
if (char === '\n' || char === '\r' || char === '}') {
115+
return index;
116+
}
117+
118+
if (char && /\s/.test(char)) {
119+
index += 1;
120+
continue;
121+
}
122+
123+
if (char === '/' && nextChar === '/') {
124+
return index;
125+
}
126+
127+
if (char === '/' && nextChar === '*') {
128+
const commentEndIndex = userCode.indexOf('*/', index + 2);
129+
if (commentEndIndex === -1) {
130+
return undefined;
131+
}
132+
133+
const comment = userCode.slice(index + 2, commentEndIndex);
134+
if (comment.includes('\n') || comment.includes('\r')) {
135+
return index;
136+
}
137+
138+
index = commentEndIndex + 2;
139+
continue;
140+
}
141+
142+
return undefined;
143+
}
144+
145+
return index;
146+
}
16147

17148
/**
18149
* Set values on the global/window object at the start of a module.
@@ -36,7 +167,6 @@ export default function valueInjectionLoader(this: LoaderThis<ValueInjectionLoad
36167
.map(([key, value]) => `globalThis["${key}"] = ${JSON.stringify(value)};`)
37168
.join('');
38169

39-
return userCode.replace(SKIP_COMMENT_AND_DIRECTIVE_REGEX, match => {
40-
return match + injectedCode;
41-
});
170+
const injectionIndex = findInjectionIndexAfterDirectives(userCode);
171+
return `${userCode.slice(0, injectionIndex)}${injectedCode}${userCode.slice(injectionIndex)}`;
42172
}

packages/nextjs/test/config/moduleMetadataInjectionLoader.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,30 @@ describe('moduleMetadataInjectionLoader', () => {
131131

132132
expect(result).toContain('"_sentryBundlerPluginAppKey:test-key-123":true');
133133
});
134+
135+
it('should inject after multiple directives', () => {
136+
const loaderThis = createLoaderThis('my-app');
137+
const userCode = '"use strict";\n"use client";\nimport React from \'react\';';
138+
139+
const result = moduleMetadataInjectionLoader.call(loaderThis, userCode);
140+
141+
const metadataIndex = result.indexOf('_sentryModuleMetadata');
142+
const clientDirectiveIndex = result.indexOf('"use client"');
143+
const importIndex = result.indexOf("import React from 'react';");
144+
145+
expect(metadataIndex).toBeGreaterThan(clientDirectiveIndex);
146+
expect(metadataIndex).toBeLessThan(importIndex);
147+
});
148+
149+
it('should inject after comments between multiple directives', () => {
150+
const loaderThis = createLoaderThis('my-app');
151+
const userCode = '"use strict";\n/* keep */\n"use client";\nimport React from \'react\';';
152+
153+
const result = moduleMetadataInjectionLoader.call(loaderThis, userCode);
154+
155+
const metadataIndex = result.indexOf('_sentryModuleMetadata');
156+
const clientDirectiveIndex = result.indexOf('"use client"');
157+
158+
expect(metadataIndex).toBeGreaterThan(clientDirectiveIndex);
159+
});
134160
});

packages/nextjs/test/config/valueInjectionLoader.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import type { LoaderThis } from '../../src/config/loaders/types';
33
import type { ValueInjectionLoaderOptions } from '../../src/config/loaders/valueInjectionLoader';
4-
import valueInjectionLoader from '../../src/config/loaders/valueInjectionLoader';
4+
import valueInjectionLoader, { findInjectionIndexAfterDirectives } from '../../src/config/loaders/valueInjectionLoader';
55

66
const defaultLoaderThis = {
77
addDependency: () => undefined,
@@ -149,4 +149,75 @@ describe.each([[clientConfigLoaderThis], [instrumentationLoaderThis]])('valueInj
149149
expect(result).toMatchSnapshot();
150150
expect(result).toMatch(';globalThis["foo"] = "bar";');
151151
});
152+
153+
it('should correctly insert values after multiple directives', () => {
154+
const userCode = `
155+
"use strict";
156+
"use client";
157+
import * as Sentry from '@sentry/nextjs';
158+
Sentry.init();
159+
`;
160+
161+
const result = valueInjectionLoader.call(loaderThis, userCode);
162+
163+
const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";');
164+
const clientDirectiveIndex = result.indexOf('"use client"');
165+
const importIndex = result.indexOf("import * as Sentry from '@sentry/nextjs';");
166+
167+
expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex);
168+
expect(injectionIndex).toBeLessThan(importIndex);
169+
});
170+
171+
it('should correctly insert values after comments between multiple directives', () => {
172+
const userCode = `
173+
"use strict";
174+
/* keep */
175+
"use client";
176+
import * as Sentry from '@sentry/nextjs';
177+
Sentry.init();
178+
`;
179+
180+
const result = valueInjectionLoader.call(loaderThis, userCode);
181+
182+
const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";');
183+
const clientDirectiveIndex = result.indexOf('"use client"');
184+
185+
expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex);
186+
});
187+
188+
it('should correctly insert values after semicolon-free directives', () => {
189+
const userCode = `
190+
"use strict"
191+
"use client"
192+
import * as Sentry from '@sentry/nextjs';
193+
Sentry.init();
194+
`;
195+
196+
const result = valueInjectionLoader.call(loaderThis, userCode);
197+
198+
const injectionIndex = result.indexOf(';globalThis["foo"] = "bar";');
199+
const clientDirectiveIndex = result.indexOf('"use client"');
200+
201+
expect(injectionIndex).toBeGreaterThan(clientDirectiveIndex);
202+
});
203+
});
204+
205+
describe('findInjectionIndexAfterDirectives', () => {
206+
it('returns the position immediately after the last directive', () => {
207+
const userCode = '"use strict";\n"use client";\nimport React from \'react\';';
208+
209+
expect(userCode.slice(findInjectionIndexAfterDirectives(userCode))).toBe("\nimport React from 'react';");
210+
});
211+
212+
it('does not skip a string literal that is not a directive', () => {
213+
const userCode = '"use client" + suffix;';
214+
215+
expect(findInjectionIndexAfterDirectives(userCode)).toBe(0);
216+
});
217+
218+
it('treats a block comment without a line break as part of the same statement', () => {
219+
const userCode = '"use client" /* comment */ + suffix;';
220+
221+
expect(findInjectionIndexAfterDirectives(userCode)).toBe(0);
222+
});
152223
});

0 commit comments

Comments
 (0)