From c48d6947ed17eab19822a97492e3686bcf059494 Mon Sep 17 00:00:00 2001 From: Jan Martin Date: Mon, 30 Sep 2024 12:55:08 -0700 Subject: [PATCH] feat(@angular/build): set development/production condition Ensures that we consistently set "development" for non-optimized and "production" for optimized builds. This is consistent with other bundlers (Vite/webpack/parcel/...). --- modules/testing/builder/src/dev_prod_mode.ts | 54 ++++++++++ .../tests/behavior/build-conditions_spec.ts | 95 +++++++++++++++++ .../tests/behavior/build-conditions_spec.ts | 100 ++++++++++++++++++ .../tools/esbuild/application-code-bundle.ts | 7 +- .../build/src/tools/esbuild/global-scripts.ts | 2 +- .../esbuild/stylesheets/bundle-options.ts | 2 +- 6 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 modules/testing/builder/src/dev_prod_mode.ts create mode 100644 packages/angular/build/src/builders/application/tests/behavior/build-conditions_spec.ts create mode 100644 packages/angular/build/src/builders/dev-server/tests/behavior/build-conditions_spec.ts diff --git a/modules/testing/builder/src/dev_prod_mode.ts b/modules/testing/builder/src/dev_prod_mode.ts new file mode 100644 index 000000000000..d9bcbb98a845 --- /dev/null +++ b/modules/testing/builder/src/dev_prod_mode.ts @@ -0,0 +1,54 @@ +/** + * @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 { BuilderHarness } from './builder-harness'; + +export const GOOD_TARGET = './src/good.js'; +export const BAD_TARGET = './src/bad.js'; + +/** Setup project for use of conditional imports. */ +export async function setupConditionImport(harness: BuilderHarness) { + // Files that can be used as targets for the conditional import. + await harness.writeFile('src/good.ts', `export const VALUE = 'good-value';`); + await harness.writeFile('src/bad.ts', `export const VALUE = 'bad-value';`); + + // Simple application file that accesses conditional code. + await harness.writeFile( + 'src/main.ts', + `import {VALUE} from '#target'; +console.log(VALUE); +export default 42 as any; +`, + ); + + // Ensure that good/bad can be resolved from tsconfig. + const tsconfig = JSON.parse(harness.readFile('src/tsconfig.app.json')) as TypeScriptConfig; + tsconfig.compilerOptions.moduleResolution = 'bundler'; + tsconfig.files.push('good.ts', 'bad.ts'); + await harness.writeFile('src/tsconfig.app.json', JSON.stringify(tsconfig)); +} + +/** Update package.json with the given mapping for #target. */ +export async function setTargetMapping(harness: BuilderHarness, mapping: unknown) { + await harness.writeFile( + 'package.json', + JSON.stringify({ + name: 'ng-test-app', + imports: { + '#target': mapping, + }, + }), + ); +} + +interface TypeScriptConfig { + compilerOptions: { + moduleResolution: string; + }; + files: string[]; +} diff --git a/packages/angular/build/src/builders/application/tests/behavior/build-conditions_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/build-conditions_spec.ts new file mode 100644 index 000000000000..887956661c89 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/build-conditions_spec.ts @@ -0,0 +1,95 @@ +/** + * @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 { + setupConditionImport, + setTargetMapping, +} from '../../../../../../../../modules/testing/builder/src/dev_prod_mode'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "conditional imports"', () => { + beforeEach(async () => { + await setupConditionImport(harness); + }); + + interface ImportsTestCase { + name: string; + mapping: unknown; + output?: string; + } + + const GOOD_TARGET = './src/good.js'; + const BAD_TARGET = './src/bad.js'; + + const testCases: ImportsTestCase[] = [ + { name: 'simple string', mapping: GOOD_TARGET }, + { + name: 'default fallback without matching condition', + mapping: { + 'never': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'development condition', + mapping: { + 'development': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'production condition', + mapping: { + 'production': GOOD_TARGET, + 'default': BAD_TARGET, + }, + }, + { + name: 'browser condition (in browser)', + mapping: { + 'browser': GOOD_TARGET, + 'default': BAD_TARGET, + }, + }, + { + name: 'browser condition (in server)', + output: 'server/main.server.mjs', + mapping: { + 'browser': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + ]; + + for (const testCase of testCases) { + describe(testCase.name, () => { + beforeEach(async () => { + await setTargetMapping(harness, testCase.mapping); + }); + + it('resolves to expected target', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + ssr: true, + server: 'src/main.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + const outputFile = `dist/${testCase.output ?? 'browser/main.js'}`; + harness.expectFile(outputFile).content.toContain('"good-value"'); + harness.expectFile(outputFile).content.not.toContain('"bad-value"'); + }); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-conditions_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-conditions_spec.ts new file mode 100644 index 000000000000..2a7d59d8d574 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-conditions_spec.ts @@ -0,0 +1,100 @@ +/** + * @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 { + setupConditionImport, + setTargetMapping, +} from '../../../../../../../../modules/testing/builder/src/dev_prod_mode'; +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, isApplicationBuilder) => { + describe('Behavior: "conditional imports"', () => { + if (!isApplicationBuilder) { + it('requires esbuild', () => { + expect(true).toBeTrue(); + }); + + return; + } + + beforeEach(async () => { + setupTarget(harness); + + await setupConditionImport(harness); + }); + + interface ImportsTestCase { + name: string; + mapping: unknown; + output?: string; + } + + const GOOD_TARGET = './src/good.js'; + const BAD_TARGET = './src/bad.js'; + + const testCases: ImportsTestCase[] = [ + { name: 'simple string', mapping: GOOD_TARGET }, + { + name: 'default fallback without matching condition', + mapping: { + 'never': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'development condition', + mapping: { + 'development': GOOD_TARGET, + 'default': BAD_TARGET, + }, + }, + { + name: 'production condition', + mapping: { + 'production': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'browser condition (in browser)', + mapping: { + 'browser': GOOD_TARGET, + 'default': BAD_TARGET, + }, + }, + ]; + + for (const testCase of testCases) { + describe(testCase.name, () => { + beforeEach(async () => { + await setTargetMapping(harness, testCase.mapping); + }); + + it('resolves to expected target', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/main.js'); + + expect(result?.success).toBeTrue(); + const output = await response?.text(); + expect(output).toContain('good-value'); + expect(output).not.toContain('bad-value'); + }); + }); + } + }); + }, +); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index e76db46d685d..6ddc125f107c 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -564,7 +564,12 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu bundle: true, packages: 'bundle', assetNames: outputNames.media, - conditions: ['es2020', 'es2015', 'module'], + conditions: [ + 'es2020', + 'es2015', + 'module', + optimizationOptions.scripts ? 'production' : 'development', + ], resolveExtensions: ['.ts', '.tsx', '.mjs', '.js', '.cjs'], metafile: true, legalComments: options.extractLicenses ? 'none' : 'eof', diff --git a/packages/angular/build/src/tools/esbuild/global-scripts.ts b/packages/angular/build/src/tools/esbuild/global-scripts.ts index 2c178c29953a..e69812d2fd30 100644 --- a/packages/angular/build/src/tools/esbuild/global-scripts.ts +++ b/packages/angular/build/src/tools/esbuild/global-scripts.ts @@ -62,7 +62,7 @@ export function createGlobalScriptsBundleOptions( entryNames: initial ? outputNames.bundles : '[name]', assetNames: outputNames.media, mainFields: ['script', 'browser', 'main'], - conditions: ['script'], + conditions: ['script', optimizationOptions.scripts ? 'production' : 'development'], resolveExtensions: ['.mjs', '.js', '.cjs'], logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', metafile: true, diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts index 591b19178893..42196d507776 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts @@ -82,7 +82,7 @@ export function createStylesheetBundleOptions( preserveSymlinks: options.preserveSymlinks, external: options.externalDependencies, publicPath: options.publicPath, - conditions: ['style', 'sass', 'less'], + conditions: ['style', 'sass', 'less', options.optimization ? 'production' : 'development'], mainFields: ['style', 'sass'], // Unlike JS, CSS does not have implicit file extensions in the general case. // Preprocessor specific behavior is handled in each stylesheet language plugin.