From 8a0b06159f52b9fb7d0889f44c0f9ccbf269e24b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:19:22 +0000 Subject: [PATCH 1/3] fix(@angular/build): handle regex literals in source-map comment scanner Previously, `removeSourceMappingURL` did not track regular expression literals in its state machine. A quote character (`"`, `'`, or `` ` ``) inside a regex literal (such as `/"/`) was misinterpreted as the start of a string literal, inverting string boundaries for the remainder of the file and corrupting valid JavaScript when a subsequent string contained `//# sourceMappingURL=`. Closes #34590 --- .../angular/build/src/utils/source-map.ts | 140 +++++++++++++++++- .../build/src/utils/source-map_spec.ts | 53 +++++++ 2 files changed, 190 insertions(+), 3 deletions(-) diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index 4fa97dd222c1..2410a2cf418a 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -59,7 +59,8 @@ export function findTrailingSourceMapComment( /** * Removes `//# sourceMappingURL=` comments safely from the given JavaScript code, - * ignoring any occurrences that are inside string literals, template literals, or block comments. + * ignoring any occurrences that are inside string literals, template literals, regex literals, + * or block comments. * * For raw Uint8Array / Buffer inputs, it optimizes performance by inspecting trailing byte sequences * to slice the buffer directly without full string decoding. @@ -93,6 +94,59 @@ export function removeSourceMappingURL(code: string | Uint8Array): string | Uint return stripped === text ? code : Buffer.from(stripped, 'utf-8'); } +const REGEX_PRECEDING_KEYWORDS = new Set([ + 'await', + 'case', + 'delete', + 'do', + 'else', + 'in', + 'instanceof', + 'new', + 'of', + 'return', + 'throw', + 'typeof', + 'void', + 'yield', +]); + +const REGEX_PRECEDING_PUNCTUATION = new Set([ + '(', + ',', + '=', + ':', + '[', + '!', + '&', + '|', + '?', + '{', + '}', + ';', + '+', + '-', + '*', + '%', + '~', + '^', + '<', + '>', + '/', +]); + +function isIdentifierChar(char: string): boolean { + const code = char.charCodeAt(0); + + return ( + (code >= 97 && code <= 122) || // a-z + (code >= 65 && code <= 90) || // A-Z + (code >= 48 && code <= 57) || // 0-9 + code === 95 || // _ + code === 36 // $ + ); +} + function removeSourceMappingURLFromString(code: string): string { if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) { return code; @@ -106,8 +160,15 @@ function removeSourceMappingURLFromString(code: string): string { // Stack to track template literal state and curly brace depth for nested interpolations. const stack: { type: 'template'; braceDepth: number }[] = []; let currentState: - 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = - 'normal'; + | 'normal' + | 'string_double' + | 'string_single' + | 'template' + | 'regex' + | 'comment_block' + | 'comment_line' = 'normal'; + let regexAllowed = true; + let inRegexCharClass = false; while (i < len) { const char = code[i]; @@ -156,17 +217,20 @@ function removeSourceMappingURLFromString(code: string): string { } if (char === '"') { currentState = 'string_double'; + regexAllowed = false; i++; continue; } if (char === "'") { currentState = 'string_single'; + regexAllowed = false; i++; continue; } if (char === '`') { currentState = 'template'; stack.push({ type: 'template', braceDepth: 0 }); + regexAllowed = false; i++; continue; } @@ -175,6 +239,7 @@ function removeSourceMappingURLFromString(code: string): string { if (top) { top.braceDepth++; } + regexAllowed = true; i++; continue; } @@ -186,14 +251,53 @@ function removeSourceMappingURLFromString(code: string): string { // Exiting a template literal interpolation ${ ... } stack.pop(); currentState = 'template'; + regexAllowed = false; i++; continue; } } + regexAllowed = true; + i++; + continue; + } + if (char === '/') { + if (regexAllowed) { + currentState = 'regex'; + inRegexCharClass = false; + regexAllowed = false; + } else { + regexAllowed = true; + } + i++; + continue; + } + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + i++; + continue; + } + if (isIdentifierChar(char)) { + const start = i; i++; + while (i < len && isIdentifierChar(code[i])) { + i++; + } + const word = code.slice(start, i); + let prevIdx = start - 1; + while ( + prevIdx >= 0 && + (code[prevIdx] === ' ' || + code[prevIdx] === '\t' || + code[prevIdx] === '\n' || + code[prevIdx] === '\r') + ) { + prevIdx--; + } + regexAllowed = + (prevIdx < 0 || code[prevIdx] !== '.') && REGEX_PRECEDING_KEYWORDS.has(word); continue; } + regexAllowed = REGEX_PRECEDING_PUNCTUATION.has(char); i++; } else if (currentState === 'string_double') { if (char === '\\') { @@ -202,6 +306,7 @@ function removeSourceMappingURLFromString(code: string): string { } if (char === '"') { currentState = 'normal'; + regexAllowed = false; } i++; } else if (currentState === 'string_single') { @@ -211,6 +316,7 @@ function removeSourceMappingURLFromString(code: string): string { } if (char === "'") { currentState = 'normal'; + regexAllowed = false; } i++; } else if (currentState === 'template') { @@ -222,12 +328,40 @@ function removeSourceMappingURLFromString(code: string): string { // Entering template literal interpolation context currentState = 'normal'; stack.push({ type: 'template', braceDepth: 0 }); + regexAllowed = true; i += 2; continue; } if (char === '`') { stack.pop(); currentState = 'normal'; + regexAllowed = false; + } + i++; + } else if (currentState === 'regex') { + if (char === '\\') { + if (nextChar === '\n' || nextChar === '\r') { + currentState = 'normal'; + inRegexCharClass = false; + regexAllowed = true; + } + i += 2; + continue; + } + if (char === '\n' || char === '\r') { + currentState = 'normal'; + inRegexCharClass = false; + regexAllowed = true; + i++; + continue; + } + if (char === '[') { + inRegexCharClass = true; + } else if (char === ']' && inRegexCharClass) { + inRegexCharClass = false; + } else if (char === '/' && !inRegexCharClass) { + currentState = 'normal'; + regexAllowed = false; } i++; } else if (currentState === 'comment_block') { diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts index a5680d61fa4f..7f40d68fb46d 100644 --- a/packages/angular/build/src/utils/source-map_spec.ts +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -83,11 +83,64 @@ describe('removeSourceMappingURL', () => { expect(removeSourceMappingURL(code)).toBe(code); }); + it('should not corrupt strings following a regex literal containing a double quote', () => { + const code = 'const pattern = /"/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not corrupt strings following a regex literal containing single quotes or backticks', () => { + const singleQuoteCode = + "const pattern = /'/; const text = '//# sourceMappingURL=not-a-comment';"; + expect(removeSourceMappingURL(singleQuoteCode)).toBe(singleQuoteCode); + + const backtickCode = 'const pattern = /`/; const text = `//# sourceMappingURL=not-a-comment`;'; + expect(removeSourceMappingURL(backtickCode)).toBe(backtickCode); + }); + + it('should handle regex character classes containing slashes and quotes', () => { + const code = + 'const pattern = /[/"\'`]/; const text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const pattern = /[/"\'`]/; const text = "//# sourceMappingURL=not-a-comment";\n', + ); + + const charClassComment = 'const re = /[//# sourceMappingURL=foo.js.map]/;'; + expect(removeSourceMappingURL(charClassComment)).toBe(charClassComment); + }); + + it('should handle escaped slashes, brackets, and quotes inside regex literals', () => { + const code = + 'const pattern = /\\/"\\[/; const text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const pattern = /\\/"\\[/; const text = "//# sourceMappingURL=not-a-comment";\n', + ); + + const escapedBracketInClass = + 'const pattern = /[\\]/"\']/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(escapedBracketInClass)).toBe(escapedBracketInClass); + }); + + it('should handle regex literals after keywords', () => { + const code = + 'function test() { return /"/; }\nconst text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'function test() { return /"/; }\nconst text = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + it('should not affect normal division operators', () => { const code = 'const ratio = 10 / 2;\n//# sourceMappingURL=main.js.map'; expect(removeSourceMappingURL(code)).toBe('const ratio = 10 / 2;\n'); }); + it('should handle division expressions followed by strings and comments', () => { + const code = + 'const x = a / b; const y = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const x = a / b; const y = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + it('should only remove exact sourceMappingURL prefix comments', () => { const code = '// # sourceMappingURL=main.js.map\n//# sourceMappingURL=main.js.map'; expect(removeSourceMappingURL(code)).toBe('// # sourceMappingURL=main.js.map\n'); From 76e5da40f849541fb25e196f1eb82c1140f28097 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:54:53 +0000 Subject: [PATCH 2/3] fixup! fix(@angular/build): handle regex literals in source-map comment scanner --- .../angular/build/src/utils/source-map.ts | 141 +++++++++--------- .../build/src/utils/source-map_spec.ts | 28 +++- 2 files changed, 99 insertions(+), 70 deletions(-) diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index 2410a2cf418a..655dc1ec695f 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -97,6 +97,7 @@ export function removeSourceMappingURL(code: string | Uint8Array): string | Uint const REGEX_PRECEDING_KEYWORDS = new Set([ 'await', 'case', + 'default', 'delete', 'do', 'else', @@ -143,10 +144,71 @@ function isIdentifierChar(char: string): boolean { (code >= 65 && code <= 90) || // A-Z (code >= 48 && code <= 57) || // 0-9 code === 95 || // _ - code === 36 // $ + code === 36 || // $ + code > 127 // Unicode / non-ASCII ); } +function consumeIdentifier( + code: string, + start: number, +): { nextIndex: number; regexAllowed: boolean } { + const len = code.length; + let i = start + 1; + while (i < len && isIdentifierChar(code[i])) { + i++; + } + const word = code.slice(start, i); + let prevIdx = start - 1; + while ( + prevIdx >= 0 && + (code[prevIdx] === ' ' || + code[prevIdx] === '\t' || + code[prevIdx] === '\n' || + code[prevIdx] === '\r') + ) { + prevIdx--; + } + const regexAllowed = + (prevIdx < 0 || code[prevIdx] !== '.') && REGEX_PRECEDING_KEYWORDS.has(word); + + return { nextIndex: i, regexAllowed }; +} + +function consumeRegexLiteral( + code: string, + start: number, +): { nextIndex: number; regexAllowed: boolean } { + const len = code.length; + let i = start; + let inCharClass = false; + + while (i < len) { + const char = code[i]; + if (char === '\\') { + const nextChar = code[i + 1]; + if (nextChar === '\n' || nextChar === '\r') { + return { nextIndex: i + 2, regexAllowed: true }; + } + i += 2; + continue; + } + if (char === '\n' || char === '\r') { + return { nextIndex: i + 1, regexAllowed: true }; + } + if (char === '[') { + inCharClass = true; + } else if (char === ']' && inCharClass) { + inCharClass = false; + } else if (char === '/' && !inCharClass) { + return { nextIndex: i + 1, regexAllowed: false }; + } + i++; + } + + return { nextIndex: len, regexAllowed: false }; +} + function removeSourceMappingURLFromString(code: string): string { if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) { return code; @@ -164,11 +226,9 @@ function removeSourceMappingURLFromString(code: string): string { | 'string_double' | 'string_single' | 'template' - | 'regex' | 'comment_block' | 'comment_line' = 'normal'; let regexAllowed = true; - let inRegexCharClass = false; while (i < len) { const char = code[i]; @@ -181,24 +241,7 @@ function removeSourceMappingURLFromString(code: string): string { continue; } if (char === '/' && nextChar === '/') { - // Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`). - let isEscaped = false; - let prevIdx = i - 1; - while (prevIdx >= 0 && /\s/.test(code[prevIdx])) { - prevIdx--; - } - if (prevIdx >= 0 && code[prevIdx] === '\\') { - let bsCount = 0; - while (prevIdx >= 0 && code[prevIdx] === '\\') { - bsCount++; - prevIdx--; - } - if (bsCount % 2 === 1) { - isEscaped = true; - } - } - - if (!isEscaped && code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { + if (code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { if (i > lastCopiedIndex) { result.push(code.slice(lastCopiedIndex, i)); } @@ -260,15 +303,18 @@ function removeSourceMappingURLFromString(code: string): string { i++; continue; } + if ((char === '+' && nextChar === '+') || (char === '-' && nextChar === '-')) { + regexAllowed = false; + i += 2; + continue; + } if (char === '/') { if (regexAllowed) { - currentState = 'regex'; - inRegexCharClass = false; - regexAllowed = false; + ({ nextIndex: i, regexAllowed } = consumeRegexLiteral(code, i + 1)); } else { regexAllowed = true; + i++; } - i++; continue; } if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { @@ -276,24 +322,7 @@ function removeSourceMappingURLFromString(code: string): string { continue; } if (isIdentifierChar(char)) { - const start = i; - i++; - while (i < len && isIdentifierChar(code[i])) { - i++; - } - const word = code.slice(start, i); - let prevIdx = start - 1; - while ( - prevIdx >= 0 && - (code[prevIdx] === ' ' || - code[prevIdx] === '\t' || - code[prevIdx] === '\n' || - code[prevIdx] === '\r') - ) { - prevIdx--; - } - regexAllowed = - (prevIdx < 0 || code[prevIdx] !== '.') && REGEX_PRECEDING_KEYWORDS.has(word); + ({ nextIndex: i, regexAllowed } = consumeIdentifier(code, i)); continue; } @@ -338,32 +367,6 @@ function removeSourceMappingURLFromString(code: string): string { regexAllowed = false; } i++; - } else if (currentState === 'regex') { - if (char === '\\') { - if (nextChar === '\n' || nextChar === '\r') { - currentState = 'normal'; - inRegexCharClass = false; - regexAllowed = true; - } - i += 2; - continue; - } - if (char === '\n' || char === '\r') { - currentState = 'normal'; - inRegexCharClass = false; - regexAllowed = true; - i++; - continue; - } - if (char === '[') { - inRegexCharClass = true; - } else if (char === ']' && inRegexCharClass) { - inRegexCharClass = false; - } else if (char === '/' && !inRegexCharClass) { - currentState = 'normal'; - regexAllowed = false; - } - i++; } else if (currentState === 'comment_block') { if (char === '*' && nextChar === '/') { currentState = 'normal'; diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts index 7f40d68fb46d..03c422b5003b 100644 --- a/packages/angular/build/src/utils/source-map_spec.ts +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -120,12 +120,38 @@ describe('removeSourceMappingURL', () => { expect(removeSourceMappingURL(escapedBracketInClass)).toBe(escapedBracketInClass); }); - it('should handle regex literals after keywords', () => { + it('should handle regex literals after keywords including default', () => { const code = 'function test() { return /"/; }\nconst text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; expect(removeSourceMappingURL(code)).toBe( 'function test() { return /"/; }\nconst text = "//# sourceMappingURL=not-a-comment";\n', ); + + const exportDefaultCode = + 'export default /"/;\nconst text = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(exportDefaultCode)).toBe( + 'export default /"/;\nconst text = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + + it('should handle Unicode identifiers before regex literals and division', () => { + const regexAfterUnicodeVar = + 'const à = /"/; const text = "//# sourceMappingURL=not-a-comment";'; + expect(removeSourceMappingURL(regexAfterUnicodeVar)).toBe(regexAfterUnicodeVar); + + const divisionAfterUnicodeVar = + 'const x = à / b; const y = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(divisionAfterUnicodeVar)).toBe( + 'const x = à / b; const y = "//# sourceMappingURL=not-a-comment";\n', + ); + }); + + it('should handle postfix increment and decrement before division', () => { + const code = + 'const x = i++ / 2 + j-- / 2; const y = "//# sourceMappingURL=not-a-comment";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe( + 'const x = i++ / 2 + j-- / 2; const y = "//# sourceMappingURL=not-a-comment";\n', + ); }); it('should not affect normal division operators', () => { From eba77744b627463dbc61b9bc978476d81b81d34c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:06:36 +0000 Subject: [PATCH 3/3] fixup! fix(@angular/build): handle regex literals in source-map comment scanner --- packages/angular/build/src/utils/source-map.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts index 655dc1ec695f..54f8da15e830 100644 --- a/packages/angular/build/src/utils/source-map.ts +++ b/packages/angular/build/src/utils/source-map.ts @@ -169,8 +169,7 @@ function consumeIdentifier( ) { prevIdx--; } - const regexAllowed = - (prevIdx < 0 || code[prevIdx] !== '.') && REGEX_PRECEDING_KEYWORDS.has(word); + const regexAllowed = (prevIdx < 0 || code[prevIdx] !== '.') && REGEX_PRECEDING_KEYWORDS.has(word); return { nextIndex: i, regexAllowed }; } @@ -222,12 +221,8 @@ function removeSourceMappingURLFromString(code: string): string { // Stack to track template literal state and curly brace depth for nested interpolations. const stack: { type: 'template'; braceDepth: number }[] = []; let currentState: - | 'normal' - | 'string_double' - | 'string_single' - | 'template' - | 'comment_block' - | 'comment_line' = 'normal'; + 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = + 'normal'; let regexAllowed = true; while (i < len) {