diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-external-dependencies_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-external-dependencies_spec.ts new file mode 100644 index 000000000000..42405a75181e --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-external-dependencies_spec.ts @@ -0,0 +1,49 @@ +/** + * @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 { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "browser builder external dependencies"', () => { + beforeEach(async () => { + setupTarget(harness, { + externalDependencies: ['rxjs', 'rxjs/operators'], + }); + + await harness.writeFile( + 'src/main.ts', + ` + import { BehaviorSubject } from 'rxjs'; + import { map } from 'rxjs/operators'; + + const subject = new BehaviorSubject('hello'); + console.log(subject.value); + + subject.pipe(map((val) => val + ' there')).subscribe(console.log); + `, + ); + }); + + it('respects import specifiers for externalized dependencies', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'main.js'); + + expect(result?.success).toBeTrue(); + + const text = await response?.text(); + expect(text).toContain(`import { BehaviorSubject } from "rxjs";`); + expect(text).toContain(`import { map } from "rxjs/operators";`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/vite-server.ts b/packages/angular/build/src/builders/dev-server/vite-server.ts index 27d6fd4cd840..239abd4401c7 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -16,6 +16,7 @@ import { basename, join } from 'node:path'; import type { Connect, DepOptimizationConfig, InlineConfig, ViteDevServer } from 'vite'; import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugin'; import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin'; +import { createRemoveIdPrefixPlugin } from '../../tools/vite/id-prefix-plugin'; import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils'; import { loadEsmModule } from '../../utils/load-esm'; import { ApplicationBuilderOutput } from '../application'; @@ -577,6 +578,7 @@ export async function setupServer( extensionMiddleware, normalizePath, }), + createRemoveIdPrefixPlugin(externalMetadata.explicit), ], // Browser only optimizeDeps. (This does not run for SSR dependencies). optimizeDeps: getDepOptimizationConfig({ diff --git a/packages/angular/build/src/tools/vite/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/id-prefix-plugin.ts new file mode 100644 index 000000000000..1cfe294dca05 --- /dev/null +++ b/packages/angular/build/src/tools/vite/id-prefix-plugin.ts @@ -0,0 +1,52 @@ +/** + * @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 type { Plugin } from 'vite'; + +// NOTE: the implementation for this Vite plugin is roughly based on: +// https://github.com/MilanKovacic/vite-plugin-externalize-dependencies + +const VITE_ID_PREFIX = '/@id/'; + +const escapeRegexSpecialChars = (inputString: string): string => { + return inputString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +}; + +export const createRemoveIdPrefixPlugin = (externals: string[]): Plugin => ({ + name: 'vite-plugin-remove-id-prefix', + apply: 'serve', + configResolved: (resolvedConfig) => { + // don't do anything when the list of externals is empty + if (externals.length === 0) { + return; + } + + const escapedExternals = externals.map(escapeRegexSpecialChars); + const prefixedExternalRegex = new RegExp( + `${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, + 'g', + ); + + // @ts-expect-error: Property 'push' does not exist on type 'readonly Plugin[]' + // Reasoning: + // since the /@id/ prefix is added by Vite's import-analysis plugin, + // we must add our actual plugin dynamically, to ensure that it will run + // AFTER the import-analysis. + resolvedConfig.plugins.push({ + name: 'vite-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); + }, + }); + }, +});