From df0fed7a84fc9f44e353249a1b0df321a9681b32 Mon Sep 17 00:00:00 2001 From: Festim Date: Tue, 7 Jul 2026 16:02:41 +0200 Subject: [PATCH 1/2] fix(@angular/build): strip all vite id prefixes from minified code with external dependencies The regex used by `createRemoveIdPrefixPlugin` appended a greedy `(?:/.+)?` to each external package name. On minified single-line output the first match on an external with a deep import path (e.g. `@angular/common/http`) consumed the remainder of the line, so subsequent `/@id/` specifiers were never stripped and failed in the browser with an "Unsupported Content-Type" error. The suffix is now bounded so a match cannot extend past the end of an import specifier string literal. Fixes #33524 --- .../tools/vite/plugins/id-prefix-plugin.ts | 8 +- .../vite/plugins/id-prefix-plugin_spec.ts | 94 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts index 50bbcd8d11e2..217be29569a8 100644 --- a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts @@ -29,7 +29,13 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin { return; } - const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/.+)?'); + // The path suffix is bounded so that a match can never extend past the end of an + // import specifier string literal. With a greedy `.+`, minified (single-line) code + // would let the first match consume the remainder of the line, leaving all later + // `/@id/` occurrences on that line unstripped. + const escapedExternals = externals.map( + (e) => escapeRegexSpecialChars(e) + '(?:/[^\'"`\\s]+)?', + ); const prefixedExternalRegex = new RegExp( `${resolvedConfig.base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, 'g', diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts new file mode 100644 index 000000000000..2ef4a7fa8f50 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createRemoveIdPrefixPlugin } from './id-prefix-plugin'; + +/** + * Runs the plugin's `configResolved` hook against a minimal fake resolved + * configuration and returns the transform function of the plugin it registers. + */ +function getTransform(externals: string[], base: string): (code: string) => string { + const plugin = createRemoveIdPrefixPlugin(externals); + const resolvedConfig = { + base, + plugins: [] as { name: string; transform?: (code: string) => string }[], + }; + + (plugin.configResolved as (config: unknown) => void)(resolvedConfig); + + const pushedPlugin = resolvedConfig.plugins[0]; + expect(pushedPlugin?.transform).toBeDefined(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return pushedPlugin.transform!; +} + +describe('createRemoveIdPrefixPlugin', () => { + it('should strip the prefix from every occurrence on a single (minified) line', () => { + const transform = getTransform( + ['@angular/common', '@angular/common/http', '@angular/core', '@angular/router'], + '/', + ); + + const minified = + 'import{a}from"/@id/@angular/common/http";' + + 'import{b}from"/@id/@angular/router";' + + 'import{c}from"/@id/@angular/core";'; + + expect(transform(minified)).toBe( + 'import{a}from"@angular/common/http";' + + 'import{b}from"@angular/router";' + + 'import{c}from"@angular/core";', + ); + }); + + it('should strip the prefix from an external with a deep import path', () => { + const transform = getTransform(['@angular/common'], '/'); + + expect(transform('import{h}from"/@id/@angular/common/http";')).toBe( + 'import{h}from"@angular/common/http";', + ); + }); + + it('should strip the prefix when a non-root base is configured', () => { + const transform = getTransform(['@angular/router'], '/app/'); + + expect(transform('import{r}from"/app/@id/@angular/router";')).toBe( + 'import{r}from"@angular/router";', + ); + }); + + it('should strip the prefix from multi-line (unminified) code', () => { + const transform = getTransform(['@angular/common', '@angular/router'], '/'); + + const code = + 'import { CommonModule } from "/@id/@angular/common";\n' + + 'import { Router } from "/@id/@angular/router";\n'; + + expect(transform(code)).toBe( + 'import { CommonModule } from "@angular/common";\n' + + 'import { Router } from "@angular/router";\n', + ); + }); + + it('should not modify imports that are not configured externals', () => { + const transform = getTransform(['@angular/router'], '/'); + + const code = 'import{x}from"/@id/some-other-package";'; + expect(transform(code)).toBe(code); + }); + + it('should not register a transform when there are no externals', () => { + const plugin = createRemoveIdPrefixPlugin([]); + const resolvedConfig = { base: '/', plugins: [] }; + + (plugin.configResolved as (config: unknown) => void)(resolvedConfig); + + expect(resolvedConfig.plugins).toHaveSize(0); + }); +}); From f6ef3de279bf5f15ab62bff42db0bbf31d74c6da Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:18:08 +0000 Subject: [PATCH 2/2] fixup! fix(@angular/build): strip all vite id prefixes from minified code with external dependencies --- .../tools/vite/plugins/id-prefix-plugin.ts | 47 ++++++++++------- .../vite/plugins/id-prefix-plugin_spec.ts | 51 +++++-------------- 2 files changed, 40 insertions(+), 58 deletions(-) diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts index 217be29569a8..74cd03179a7e 100644 --- a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts @@ -29,17 +29,7 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin { return; } - // The path suffix is bounded so that a match can never extend past the end of an - // import specifier string literal. With a greedy `.+`, minified (single-line) code - // would let the first match consume the remainder of the line, leaving all later - // `/@id/` occurrences on that line unstripped. - const escapedExternals = externals.map( - (e) => escapeRegexSpecialChars(e) + '(?:/[^\'"`\\s]+)?', - ); - const prefixedExternalRegex = new RegExp( - `${resolvedConfig.base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, - 'g', - ); + const transformFn = createTransformer(resolvedConfig.base, externals); // @ts-expect-error: Property 'push' does not exist on type 'readonly Plugin[]' // Reasoning: @@ -48,15 +38,34 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin { // AFTER the import-analysis. resolvedConfig.plugins.push({ name: 'angular-plugin-remove-id-prefix-transform', - transform: (code: string) => { - // don't do anything when code does not contain the Vite prefix - if (!code.includes(VITE_ID_PREFIX)) { - return code; - } - - return code.replace(prefixedExternalRegex, (_, externalName) => externalName); - }, + transform: transformFn, }); }, }; } + +/** + * Creates a transform function that removes the Vite ID prefix from externals. + * @param base The base path of the application. + * @param externals The external package names. + * @returns A function that transforms code by removing the Vite ID prefix. + */ +export function createTransformer(base: string, externals: string[]): (code: string) => string { + // The path suffix is bounded so that a match can never extend past the end of an + // import specifier string literal. With a greedy `.+`, minified (single-line) code + // would let the first match consume the remainder of the line, leaving all later + // `/@id/` occurrences on that line unstripped. + const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/[^\'"`\\s]+)?'); + + const prefixedExternalRegex = new RegExp( + `${base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, + 'g', + ); + + return (code: string) => { + return code.includes(VITE_ID_PREFIX) + ? code.replace(prefixedExternalRegex, (_, externalName) => externalName) + : // don't do anything when code does not contain the Vite prefix + code; + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts index 2ef4a7fa8f50..37d1bdf61fe6 100644 --- a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts @@ -6,34 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import { createRemoveIdPrefixPlugin } from './id-prefix-plugin'; +import { createTransformer } from './id-prefix-plugin'; -/** - * Runs the plugin's `configResolved` hook against a minimal fake resolved - * configuration and returns the transform function of the plugin it registers. - */ -function getTransform(externals: string[], base: string): (code: string) => string { - const plugin = createRemoveIdPrefixPlugin(externals); - const resolvedConfig = { - base, - plugins: [] as { name: string; transform?: (code: string) => string }[], - }; - - (plugin.configResolved as (config: unknown) => void)(resolvedConfig); - - const pushedPlugin = resolvedConfig.plugins[0]; - expect(pushedPlugin?.transform).toBeDefined(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return pushedPlugin.transform!; -} - -describe('createRemoveIdPrefixPlugin', () => { +describe('createTransformer', () => { it('should strip the prefix from every occurrence on a single (minified) line', () => { - const transform = getTransform( - ['@angular/common', '@angular/common/http', '@angular/core', '@angular/router'], - '/', - ); + const transform = createTransformer('/', [ + '@angular/common', + '@angular/common/http', + '@angular/core', + '@angular/router', + ]); const minified = 'import{a}from"/@id/@angular/common/http";' + @@ -48,7 +30,7 @@ describe('createRemoveIdPrefixPlugin', () => { }); it('should strip the prefix from an external with a deep import path', () => { - const transform = getTransform(['@angular/common'], '/'); + const transform = createTransformer('/', ['@angular/common']); expect(transform('import{h}from"/@id/@angular/common/http";')).toBe( 'import{h}from"@angular/common/http";', @@ -56,7 +38,7 @@ describe('createRemoveIdPrefixPlugin', () => { }); it('should strip the prefix when a non-root base is configured', () => { - const transform = getTransform(['@angular/router'], '/app/'); + const transform = createTransformer('/app/', ['@angular/router']); expect(transform('import{r}from"/app/@id/@angular/router";')).toBe( 'import{r}from"@angular/router";', @@ -64,7 +46,7 @@ describe('createRemoveIdPrefixPlugin', () => { }); it('should strip the prefix from multi-line (unminified) code', () => { - const transform = getTransform(['@angular/common', '@angular/router'], '/'); + const transform = createTransformer('/', ['@angular/common', '@angular/router']); const code = 'import { CommonModule } from "/@id/@angular/common";\n' + @@ -77,18 +59,9 @@ describe('createRemoveIdPrefixPlugin', () => { }); it('should not modify imports that are not configured externals', () => { - const transform = getTransform(['@angular/router'], '/'); + const transform = createTransformer('/', ['@angular/router']); const code = 'import{x}from"/@id/some-other-package";'; expect(transform(code)).toBe(code); }); - - it('should not register a transform when there are no externals', () => { - const plugin = createRemoveIdPrefixPlugin([]); - const resolvedConfig = { base: '/', plugins: [] }; - - (plugin.configResolved as (config: unknown) => void)(resolvedConfig); - - expect(resolvedConfig.plugins).toHaveSize(0); - }); });