diff --git a/package.json b/package.json index 9d2435d3..622737f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/build", - "version": "20.0.0-next.8+sha-bbb08d6", + "version": "20.0.6+sha-82cf0cb", "description": "Official build system for Angular", "keywords": [ "Angular CLI", @@ -23,47 +23,47 @@ "builders": "builders.json", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "github:angular/angular-devkit-architect-builds#{BUILD_SCM_ABBREV_HASH}", - "@babel/core": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", + "@angular-devkit/architect": "github:angular/angular-devkit-architect-builds#82cf0cb", + "@babel/core": "7.27.1", + "@babel/helper-annotate-as-pure": "7.27.1", "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "5.1.9", + "@inquirer/confirm": "5.1.10", "@vitejs/plugin-basic-ssl": "2.0.0", - "beasties": "0.3.3", + "beasties": "0.3.4", "browserslist": "^4.23.0", - "esbuild": "0.25.3", + "esbuild": "0.25.5", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", - "listr2": "8.3.2", + "listr2": "8.3.3", "magic-string": "0.30.17", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "7.1.0", "picomatch": "4.0.2", - "piscina": "4.9.2", - "rollup": "4.40.1", - "sass": "1.87.0", - "semver": "7.7.1", + "piscina": "5.1.1", + "rollup": "4.40.2", + "sass": "1.88.0", + "semver": "7.7.2", "source-map-support": "0.5.21", "tinyglobby": "0.2.13", - "vite": "6.3.4", + "vite": "6.3.5", "watchpack": "2.4.2" }, "optionalDependencies": { - "lmdb": "3.2.6" + "lmdb": "3.3.0" }, "peerDependencies": { - "@angular/core": "^20.0.0 || ^20.0.0-next.0", - "@angular/compiler": "^20.0.0 || ^20.0.0-next.0", - "@angular/compiler-cli": "^20.0.0 || ^20.0.0-next.0", - "@angular/localize": "^20.0.0 || ^20.0.0-next.0", - "@angular/platform-browser": "^20.0.0 || ^20.0.0-next.0", - "@angular/platform-server": "^20.0.0 || ^20.0.0-next.0", - "@angular/service-worker": "^20.0.0 || ^20.0.0-next.0", - "@angular/ssr": "github:angular/angular-ssr-builds#{BUILD_SCM_ABBREV_HASH}", + "@angular/core": "^20.0.0", + "@angular/compiler": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@angular/localize": "^20.0.0", + "@angular/platform-browser": "^20.0.0", + "@angular/platform-server": "^20.0.0", + "@angular/service-worker": "^20.0.0", + "@angular/ssr": "github:angular/angular-ssr-builds#82cf0cb", "karma": "^6.4.0", "less": "^4.2.0", - "ng-packagr": "^20.0.0 || ^20.0.0-next.0", + "ng-packagr": "^20.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", @@ -86,7 +86,9 @@ "@angular/service-worker": { "optional": true }, - "@angular/ssr": "github:angular/angular-ssr-builds#{BUILD_SCM_ABBREV_HASH}", + "@angular/ssr": { + "optional": true + }, "karma": { "optional": true }, @@ -110,9 +112,9 @@ "type": "git", "url": "https://github.com/angular/angular-cli.git" }, - "packageManager": "pnpm@9.15.6", + "packageManager": "pnpm@9.15.9", "engines": { - "node": "^20.11.1 || >=22.11.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, diff --git a/src/builders/application/execute-build.js b/src/builders/application/execute-build.js index 7c6901ff..10978133 100644 --- a/src/builders/application/execute-build.js +++ b/src/builders/application/execute-build.js @@ -229,8 +229,9 @@ async function executeBuild(options, context, rebuildState) { const result = await (0, execute_post_bundle_1.executePostBundleSteps)(metafile, options, executionResult.outputFiles, executionResult.assetFiles, initialFiles, // Set lang attribute to the defined source locale if present i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined); - executionResult.addErrors(result.errors); - executionResult.addWarnings(result.warnings); + // Deduplicate and add errors and warnings + executionResult.addErrors([...new Set(result.errors)]); + executionResult.addWarnings([...new Set(result.warnings)]); executionResult.addPrerenderedRoutes(result.prerenderedRoutes); executionResult.outputFiles.push(...result.additionalOutputFiles); executionResult.assetFiles.push(...result.additionalAssets); diff --git a/src/builders/application/i18n.js b/src/builders/application/i18n.js index b299c6c2..97eeca66 100644 --- a/src/builders/application/i18n.js +++ b/src/builders/application/i18n.js @@ -92,6 +92,21 @@ async function inlineI18n(metafile, options, executionResult, initialFiles) { if (!i18nOptions.flatOutput) { executionResult.assetFiles = updatedAssetFiles; } + // Inline any template updates if present + if (executionResult.templateUpdates?.size) { + // The development server only allows a single locale but issue a warning if used programmatically (experimental) + // with multiple locales and template HMR. + if (i18nOptions.inlineLocales.size > 1) { + inlineResult.warnings.push(`Component HMR updates can only be inlined with a single locale. The first locale will be used.`); + } + const firstLocale = [...i18nOptions.inlineLocales][0]; + for (const [id, content] of executionResult.templateUpdates) { + const templateUpdateResult = await inliner.inlineTemplateUpdate(firstLocale, i18nOptions.locales[firstLocale].translation, content, id); + executionResult.templateUpdates.set(id, templateUpdateResult.code); + inlineResult.errors.push(...templateUpdateResult.errors); + inlineResult.warnings.push(...templateUpdateResult.warnings); + } + } return inlineResult; } /** diff --git a/src/builders/application/options.js b/src/builders/application/options.js index e1989236..9e774d28 100644 --- a/src/builders/application/options.js +++ b/src/builders/application/options.js @@ -69,7 +69,7 @@ async function normalizeOptions(context, projectName, options, extensions) { if (options.forceI18nFlatOutput) { i18nOptions.flatOutput = true; } - const entryPoints = normalizeEntryPoints(workspaceRoot, options.browser, options.entryPoints); + const entryPoints = normalizeEntryPoints(workspaceRoot, projectSourceRoot, options.browser, options.entryPoints); const tsconfig = node_path_1.default.join(workspaceRoot, options.tsConfig); const optimizationOptions = (0, utils_1.normalizeOptimization)(options.optimization); const sourcemapOptions = (0, utils_1.normalizeSourceMaps)(options.sourceMap ?? false); @@ -355,23 +355,22 @@ async function getTailwindConfig(searchDirectories, workspaceRoot, context) { * @param entryPoints Set of entry points to use if provided. * @returns An object mapping entry point names to their file paths. */ -function normalizeEntryPoints(workspaceRoot, browser, entryPoints = new Set()) { +function normalizeEntryPoints(workspaceRoot, projectSourceRoot, browser, entryPoints) { if (browser === '') { throw new Error('`browser` option cannot be an empty string.'); } // `browser` and `entryPoints` are mutually exclusive. - if (browser && entryPoints.size > 0) { + if (browser && entryPoints) { throw new Error('Only one of `browser` or `entryPoints` may be provided.'); } - if (!browser && entryPoints.size === 0) { - // Schema should normally reject this case, but programmatic usages of the builder might make this mistake. - throw new Error('Either `browser` or at least one `entryPoints` value must be provided.'); - } - // Schema types force `browser` to always be provided, but it may be omitted when the builder is invoked programmatically. if (browser) { // Use `browser` alone. return { 'main': node_path_1.default.join(workspaceRoot, browser) }; } + else if (!entryPoints) { + // Default browser entry if no explicit entry points + return { 'main': node_path_1.default.join(projectSourceRoot, 'main.ts') }; + } else if (entryPoints instanceof Map) { return Object.fromEntries(Array.from(entryPoints.entries(), ([name, entryPoint]) => { // Get the full file path to a relative entry point input. Leave bare specifiers alone so they are resolved as modules. diff --git a/src/builders/application/schema.d.ts b/src/builders/application/schema.d.ts index c21de968..12550e55 100644 --- a/src/builders/application/schema.d.ts +++ b/src/builders/application/schema.d.ts @@ -27,7 +27,7 @@ export type Schema = { * The full path for the browser entry point to the application, relative to the current * workspace. */ - browser: string; + browser?: string; /** * Budget thresholds to ensure parts of your application stay within boundaries which you * set. diff --git a/src/builders/application/schema.json b/src/builders/application/schema.json index 934bfe93..ef4cbb75 100644 --- a/src/builders/application/schema.json +++ b/src/builders/application/schema.json @@ -616,7 +616,7 @@ } }, "additionalProperties": false, - "required": ["browser", "tsConfig"], + "required": ["tsConfig"], "definitions": { "assetPattern": { "oneOf": [ diff --git a/src/builders/dev-server/vite-server.js b/src/builders/dev-server/vite-server.js index 472a92b5..2c9090c2 100644 --- a/src/builders/dev-server/vite-server.js +++ b/src/builders/dev-server/vite-server.js @@ -114,13 +114,12 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context // https://nodejs.org/api/process.html#processsetsourcemapsenabledval process.setSourceMapsEnabled(true); } + const componentsHmrCanBeUsed = browserOptions.aot && serverOptions.liveReload && serverOptions.hmr; // Enable to support link-based component style hot reloading (`NG_HMR_CSTYLES=1` can be used to enable) - browserOptions.externalRuntimeStyles = - serverOptions.liveReload && serverOptions.hmr && environment_options_1.useComponentStyleHmr; + browserOptions.externalRuntimeStyles = componentsHmrCanBeUsed && environment_options_1.useComponentStyleHmr; // Enable to support component template hot replacement (`NG_HMR_TEMPLATE=0` can be used to disable selectively) // This will also replace file-based/inline styles as code if external runtime styles are not enabled. - browserOptions.templateUpdates = - serverOptions.liveReload && serverOptions.hmr && environment_options_1.useComponentTemplateHmr; + browserOptions.templateUpdates = componentsHmrCanBeUsed && environment_options_1.useComponentTemplateHmr; browserOptions.incrementalResults = true; // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new internal_1.JavaScriptTransformer( diff --git a/src/builders/extract-i18n/builder.js b/src/builders/extract-i18n/builder.js index 19b0f374..bcf5ec01 100644 --- a/src/builders/extract-i18n/builder.js +++ b/src/builders/extract-i18n/builder.js @@ -100,13 +100,20 @@ async function execute(options, context, extensions) { return node_path_1.default.relative(from, to); }, }; + const duplicateTranslationBehavior = normalizedOptions.i18nOptions.duplicateTranslationBehavior; const diagnostics = checkDuplicateMessages( // eslint-disable-next-line @typescript-eslint/no-explicit-any - checkFileSystem, extractionResult.messages, normalizedOptions.i18nOptions.i18nDuplicateTranslation || 'warning', + checkFileSystem, extractionResult.messages, duplicateTranslationBehavior, // eslint-disable-next-line @typescript-eslint/no-explicit-any extractionResult.basePath); - if (diagnostics.messages.length > 0) { - context.logger.warn(diagnostics.formatDiagnostics('')); + if (diagnostics.messages.length > 0 && duplicateTranslationBehavior !== 'ignore') { + if (duplicateTranslationBehavior === 'error') { + context.logger.error(`Extraction Failed: ${diagnostics.formatDiagnostics('')}`); + return { success: false }; + } + else { + context.logger.warn(diagnostics.formatDiagnostics('')); + } } // Serialize all extracted messages const serializer = await createSerializer(localizeToolsModule, normalizedOptions.format, normalizedOptions.i18nOptions.sourceLocale, extractionResult.basePath, extractionResult.useLegacyIds, diagnostics); diff --git a/src/builders/extract-i18n/options.d.ts b/src/builders/extract-i18n/options.d.ts index b23a5b83..c2c4ace5 100644 --- a/src/builders/extract-i18n/options.d.ts +++ b/src/builders/extract-i18n/options.d.ts @@ -5,7 +5,9 @@ * 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 DiagnosticHandlingStrategy } from '@angular/localize/tools'; import { BuilderContext } from '@angular-devkit/architect'; +import { type I18nOptions } from '../../utils/i18n-options'; import { Schema as ExtractI18nOptions, Format } from './schema'; export type NormalizedExtractI18nOptions = Awaited>; /** @@ -22,7 +24,9 @@ export declare function normalizeOptions(context: BuilderContext, projectName: s workspaceRoot: string; projectRoot: string; buildTarget: import("@angular-devkit/architect").Target; - i18nOptions: import("../../utils/i18n-options").I18nOptions; + i18nOptions: I18nOptions & { + duplicateTranslationBehavior: DiagnosticHandlingStrategy; + }; format: Format.Arb | Format.Json | Format.LegacyMigrate | Format.Xliff | Format.Xliff2 | Format.Xmb; outFile: string; progress: boolean; diff --git a/src/builders/extract-i18n/options.js b/src/builders/extract-i18n/options.js index b3b50d51..d7b5fe54 100644 --- a/src/builders/extract-i18n/options.js +++ b/src/builders/extract-i18n/options.js @@ -33,7 +33,10 @@ async function normalizeOptions(context, projectName, options) { // Target specifier defaults to the current project's build target with no specified configuration const buildTargetSpecifier = options.buildTarget ?? ':'; const buildTarget = (0, architect_1.targetFromTargetString)(buildTargetSpecifier, projectName, 'build'); - const i18nOptions = (0, i18n_options_1.createI18nOptions)(projectMetadata, /** inline */ false, context.logger); + const i18nOptions = { + ...(0, i18n_options_1.createI18nOptions)(projectMetadata, /** inline */ false, context.logger), + duplicateTranslationBehavior: options.i18nDuplicateTranslation || 'warning', + }; // Normalize xliff format extensions let format = options.format; switch (format) { diff --git a/src/builders/extract-i18n/schema.d.ts b/src/builders/extract-i18n/schema.d.ts index fa72d336..b5911201 100644 --- a/src/builders/extract-i18n/schema.d.ts +++ b/src/builders/extract-i18n/schema.d.ts @@ -12,6 +12,10 @@ export type Schema = { * Output format for the generated file. */ format?: Format; + /** + * How to handle duplicate translations. + */ + i18nDuplicateTranslation?: I18NDuplicateTranslation; /** * Name of the file to output. */ @@ -39,3 +43,11 @@ export declare enum Format { Xliff2 = "xliff2", Xmb = "xmb" } +/** + * How to handle duplicate translations. + */ +export declare enum I18NDuplicateTranslation { + Error = "error", + Ignore = "ignore", + Warning = "warning" +} diff --git a/src/builders/extract-i18n/schema.js b/src/builders/extract-i18n/schema.js index 6f767a2e..3904eb68 100644 --- a/src/builders/extract-i18n/schema.js +++ b/src/builders/extract-i18n/schema.js @@ -2,7 +2,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED. TO UPDATE THIS FILE YOU NEED TO CHANGE THE // CORRESPONDING JSON SCHEMA FILE, THEN RUN devkit-admin build (or bazel build ...). Object.defineProperty(exports, "__esModule", { value: true }); -exports.Format = void 0; +exports.I18NDuplicateTranslation = exports.Format = void 0; /** * Output format for the generated file. */ @@ -18,3 +18,12 @@ var Format; Format["Xliff2"] = "xliff2"; Format["Xmb"] = "xmb"; })(Format || (exports.Format = Format = {})); +/** + * How to handle duplicate translations. + */ +var I18NDuplicateTranslation; +(function (I18NDuplicateTranslation) { + I18NDuplicateTranslation["Error"] = "error"; + I18NDuplicateTranslation["Ignore"] = "ignore"; + I18NDuplicateTranslation["Warning"] = "warning"; +})(I18NDuplicateTranslation || (exports.I18NDuplicateTranslation = I18NDuplicateTranslation = {})); diff --git a/src/builders/extract-i18n/schema.json b/src/builders/extract-i18n/schema.json index 9ab939b0..08a118ad 100644 --- a/src/builders/extract-i18n/schema.json +++ b/src/builders/extract-i18n/schema.json @@ -27,6 +27,11 @@ "outFile": { "type": "string", "description": "Name of the file to output." + }, + "i18nDuplicateTranslation": { + "type": "string", + "description": "How to handle duplicate translations.", + "enum": ["error", "warning", "ignore"] } }, "additionalProperties": false diff --git a/src/builders/karma/application_builder.js b/src/builders/karma/application_builder.js index 699584cf..e66aba61 100644 --- a/src/builders/karma/application_builder.js +++ b/src/builders/karma/application_builder.js @@ -86,13 +86,16 @@ class AngularAssetsMiddleware { next(); return; } + // Implementation of serverFile can be found here: + // https://github.com/karma-runner/karma/blob/84f85e7016efc2266fa6b3465f494a3fa151c85c/lib/middleware/common.js#L10 switch (file.origin) { case 'disk': - this.serveFile(file.inputPath, undefined, res); + this.serveFile(file.inputPath, undefined, res, undefined, undefined, /* doNotCache */ true); break; case 'memory': // Include pathname to help with Content-Type headers. - this.serveFile(`/unused/${url.pathname}`, undefined, res, undefined, file.contents, true); + this.serveFile(`/unused/${url.pathname}`, undefined, res, undefined, file.contents, + /* doNotCache */ false); break; } } @@ -112,7 +115,7 @@ class AngularAssetsMiddleware { class AngularPolyfillsPlugin { static $inject = ['config.files']; static NAME = 'angular-polyfills'; - static createPlugin(polyfillsFile, jasmineCleanupFiles) { + static createPlugin(polyfillsFile, jasmineCleanupFiles, scriptsFiles) { return { // This has to be a "reporter" because reporters run _after_ frameworks // and karma-jasmine-html-reporter injects additional scripts that may @@ -155,6 +158,8 @@ class AngularPolyfillsPlugin { f.type = 'module'; } } + // Add "scripts" option files as classic scripts + files.unshift(...scriptsFiles); // Add browser sourcemap support as a classic script files.unshift({ pattern: localResolve('source-map-support/browser-source-map-support.js'), @@ -394,16 +399,26 @@ async function initializeApplication(options, context, karmaOptions, transforms watched: false, }; karmaOptions.basePath = outputPath; - karmaOptions.files ??= []; + const scriptsFiles = []; if (options.scripts?.length) { - // This should be more granular to support named bundles. - // However, it replicates the behavior of the Karma Webpack-based builder. - karmaOptions.files.push({ - pattern: `scripts.js`, - watched: false, - type: 'js', - }); + const outputScripts = new Set(); + for (const scriptEntry of options.scripts) { + const outputName = typeof scriptEntry === 'string' + ? 'scripts.js' + : `${scriptEntry.bundleName ?? 'scripts'}.js`; + if (outputScripts.has(outputName)) { + continue; + } + outputScripts.add(outputName); + scriptsFiles.push({ + pattern: `${outputPath}/${outputName}`, + watched: false, + included: typeof scriptEntry === 'string' ? true : scriptEntry.inject !== false, + type: 'js', + }); + } } + karmaOptions.files ??= []; karmaOptions.files.push( // Serve global setup script. { pattern: `${mainName}.js`, type: 'module', watched: false }, @@ -454,9 +469,32 @@ async function initializeApplication(options, context, karmaOptions, transforms parsedKarmaConfig.plugins.push(AngularAssetsMiddleware.createPlugin(buildOutput)); parsedKarmaConfig.middleware ??= []; parsedKarmaConfig.middleware.push(AngularAssetsMiddleware.NAME); - parsedKarmaConfig.plugins.push(AngularPolyfillsPlugin.createPlugin(polyfillsFile, jasmineCleanupFiles)); + parsedKarmaConfig.plugins.push(AngularPolyfillsPlugin.createPlugin(polyfillsFile, jasmineCleanupFiles, scriptsFiles)); parsedKarmaConfig.reporters ??= []; parsedKarmaConfig.reporters.push(AngularPolyfillsPlugin.NAME); + // Adjust karma junit reporter outDir location to maintain previous (devkit) behavior + // The base path for the reporter was previously the workspace root. + // To keep the files in the same location, the reporter's output directory is adjusted + // to be relative to the workspace root when using junit. + if (parsedKarmaConfig.reporters?.some((reporter) => reporter === 'junit')) { + if ('junitReporter' in parsedKarmaConfig) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const junitReporterOptions = parsedKarmaConfig['junitReporter']; + if (junitReporterOptions.outputDir == undefined) { + junitReporterOptions.outputDir = context.workspaceRoot; + } + else if (typeof junitReporterOptions.outputDir === 'string' && + !path.isAbsolute(junitReporterOptions.outputDir)) { + junitReporterOptions.outputDir = path.join(context.workspaceRoot, junitReporterOptions.outputDir); + } + } + else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parsedKarmaConfig['junitReporter'] = { + outputDir: context.workspaceRoot, + }; + } + } // When using code-coverage, auto-add karma-coverage. // This was done as part of the karma plugin for webpack. if (options.codeCoverage && diff --git a/src/builders/karma/index.js b/src/builders/karma/index.js index f5c78b64..b8b9d13c 100644 --- a/src/builders/karma/index.js +++ b/src/builders/karma/index.js @@ -99,6 +99,7 @@ function getBuiltInKarmaConfig(workspaceRoot, projectName) { // Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template return { basePath: '', + rootUrl: '/', frameworks: ['jasmine'], plugins: [ 'karma-jasmine', diff --git a/src/builders/unit-test/builder.js b/src/builders/unit-test/builder.js index 5562b401..9960e9b2 100644 --- a/src/builders/unit-test/builder.js +++ b/src/builders/unit-test/builder.js @@ -16,6 +16,7 @@ const node_crypto_1 = require("node:crypto"); const node_module_1 = require("node:module"); const node_path_1 = __importDefault(require("node:path")); const virtual_module_plugin_1 = require("../../tools/esbuild/virtual-module-plugin"); +const error_1 = require("../../utils/error"); const load_esm_1 = require("../../utils/load-esm"); const application_1 = require("../application"); const results_1 = require("../application/results"); @@ -27,6 +28,7 @@ const options_1 = require("./options"); /** * @experimental Direct usage of this function is considered experimental. */ +// eslint-disable-next-line max-lines-per-function async function* execute(options, context, extensions = {}) { // Determine project name from builder context target const projectName = context.target?.project; @@ -55,7 +57,19 @@ async function* execute(options, context, extensions = {}) { } const entryPoints = (0, find_tests_1.getTestEntrypoints)(testFiles, { projectSourceRoot, workspaceRoot }); entryPoints.set('init-testbed', 'angular:test-bed-init'); - const { startVitest } = await (0, load_esm_1.loadEsmModule)('vitest/node'); + let vitestNodeModule; + try { + vitestNodeModule = await (0, load_esm_1.loadEsmModule)('vitest/node'); + } + catch (error) { + (0, error_1.assertIsError)(error); + if (error.code !== 'ERR_MODULE_NOT_FOUND') { + throw error; + } + context.logger.error('The `vitest` package was not found. Please install the package and rerun the test command.'); + return; + } + const { startVitest } = vitestNodeModule; // Setup test file build options based on application build target options const buildTargetOptions = (await context.validateOptions(await context.getTargetOptions(normalizedOptions.buildTarget), await context.getBuilderNameForTarget(normalizedOptions.buildTarget))); if (buildTargetOptions.polyfills?.includes('zone.js')) { @@ -65,10 +79,12 @@ async function* execute(options, context, extensions = {}) { const buildOptions = { ...buildTargetOptions, watch: normalizedOptions.watch, + incrementalResults: normalizedOptions.watch, outputPath, index: false, browser: undefined, server: undefined, + outputMode: undefined, localize: false, budgets: [], serviceWorker: false, @@ -80,7 +96,11 @@ async function* execute(options, context, extensions = {}) { optimization: false, tsConfig: normalizedOptions.tsConfig, entryPoints, - externalDependencies: ['vitest', ...(buildTargetOptions.externalDependencies ?? [])], + externalDependencies: [ + 'vitest', + '@vitest/browser/context', + ...(buildTargetOptions.externalDependencies ?? []), + ], }; extensions ??= {}; extensions.codePlugins ??= []; @@ -89,9 +109,27 @@ async function* execute(options, context, extensions = {}) { loadContent: async () => { const contents = [ // Initialize the Angular testing environment - `import { getTestBed } from '@angular/core/testing';`, + `import { NgModule } from '@angular/core';`, + `import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing';`, `import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';`, - `getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {`, + '', + normalizedOptions.providersFile + ? `import providers from './${node_path_1.default + .relative(projectSourceRoot, normalizedOptions.providersFile) + .replace(/.[mc]?ts$/, '') + .replace(/\\/g, '/')}'` + : 'const providers = [];', + '', + // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/src/test_hooks.ts#L21-L29 + `beforeEach(getCleanupHook(false));`, + `afterEach(getCleanupHook(true));`, + '', + `@NgModule({`, + ` providers,`, + `})`, + `export class TestModule {}`, + '', + `getTestBed().initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting(), {`, ` errorOnUnknownElements: true,`, ` errorOnUnknownProperties: true,`, '});', @@ -106,59 +144,83 @@ async function* execute(options, context, extensions = {}) { extensions.codePlugins.unshift(virtualTestBedInit); let instance; // Setup vitest browser options if configured - let browser; - if (normalizedOptions.browsers) { - const provider = findBrowserProvider(projectSourceRoot); - if (!provider) { - context.logger.error('The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' + - ' Please install one of these packages and rerun the test command.'); - return { success: false }; - } - browser = { - enabled: true, - provider, - instances: normalizedOptions.browsers.map((browserName) => ({ - browser: browserName, - })), - }; - } - for await (const result of (0, application_1.buildApplicationInternal)(buildOptions, context, extensions)) { - if (result.kind === results_1.ResultKind.Failure) { - continue; + const { browser, errors } = setupBrowserConfiguration(normalizedOptions.browsers, normalizedOptions.debug, projectSourceRoot); + if (errors?.length) { + errors.forEach((error) => context.logger.error(error)); + return { success: false }; + } + // Add setup file entries for TestBed initialization and project polyfills + const setupFiles = ['init-testbed.js']; + if (buildTargetOptions?.polyfills?.length) { + setupFiles.push('polyfills.js'); + } + const debugOptions = normalizedOptions.debug + ? { + inspectBrk: true, + isolate: false, + fileParallelism: false, } - else if (result.kind !== results_1.ResultKind.Full) { - node_assert_1.default.fail('A full build result is required from the application builder.'); + : {}; + try { + for await (const result of (0, application_1.buildApplicationInternal)(buildOptions, context, extensions)) { + if (result.kind === results_1.ResultKind.Failure) { + continue; + } + else if (result.kind !== results_1.ResultKind.Full && result.kind !== results_1.ResultKind.Incremental) { + node_assert_1.default.fail('A full and/or incremental build result is required from the application builder.'); + } + (0, node_assert_1.default)(result.files, 'Builder did not provide result files.'); + await (0, application_builder_1.writeTestFiles)(result.files, outputPath); + instance ??= await startVitest('test', undefined /* cliFilters */, { + // Disable configuration file resolution/loading + config: false, + }, { + test: { + root: outputPath, + globals: true, + setupFiles, + // Use `jsdom` if no browsers are explicitly configured. + // `node` is effectively no "environment" and the default. + environment: browser ? 'node' : 'jsdom', + watch: normalizedOptions.watch, + browser, + reporters: normalizedOptions.reporters ?? ['default'], + coverage: { + enabled: normalizedOptions.codeCoverage, + excludeAfterRemap: true, + }, + ...debugOptions, + }, + plugins: [ + { + name: 'angular-coverage-exclude', + configureVitest(context) { + // Adjust coverage excludes to not include the otherwise automatically inserted included unit tests. + // Vite does this as a convenience but is problematic for the bundling strategy employed by the + // builder's test setup. To workaround this, the excludes are adjusted here to only automatically + // exclude the TypeScript source test files. + context.project.config.coverage.exclude = [ + ...(normalizedOptions.codeCoverageExclude ?? []), + '**/*.{test,spec}.?(c|m)ts', + ]; + }, + }, + ], + }); + // Check if all the tests pass to calculate the result + const testModules = instance.state.getTestModules(); + yield { success: testModules.every((testModule) => testModule.ok()) }; } - (0, node_assert_1.default)(result.files, 'Builder did not provide result files.'); - await (0, application_builder_1.writeTestFiles)(result.files, outputPath); - const setupFiles = ['init-testbed.js']; - if (buildTargetOptions?.polyfills?.length) { - setupFiles.push('polyfills.js'); + } + finally { + if (normalizedOptions.watch) { + // Vitest will automatically close if not using watch mode + await instance?.close(); } - instance ??= await startVitest('test', undefined /* cliFilters */, undefined /* options */, { - test: { - root: outputPath, - setupFiles, - // Use `jsdom` if no browsers are explicitly configured. - // `node` is effectively no "environment" and the default. - environment: browser ? 'node' : 'jsdom', - watch: normalizedOptions.watch, - browser, - coverage: { - enabled: normalizedOptions.codeCoverage, - exclude: normalizedOptions.codeCoverageExclude, - excludeAfterRemap: true, - }, - }, - }); - // Check if all the tests pass to calculate the result - const testModules = instance.state.getTestModules(); - yield { success: testModules.every((testModule) => testModule.ok()) }; } } -function findBrowserProvider(projectSourceRoot) { - const projectResolver = (0, node_module_1.createRequire)(projectSourceRoot + '/').resolve; - // These must be installed in the project to be used +function findBrowserProvider(projectResolver) { + // One of these must be installed in the project to use browser testing const vitestBuiltinProviders = ['playwright', 'webdriverio']; for (const providerName of vitestBuiltinProviders) { try { @@ -168,3 +230,41 @@ function findBrowserProvider(projectSourceRoot) { catch { } } } +function setupBrowserConfiguration(browsers, debug, projectSourceRoot) { + if (browsers === undefined) { + return {}; + } + const projectResolver = (0, node_module_1.createRequire)(projectSourceRoot + '/').resolve; + let errors; + try { + projectResolver('@vitest/browser'); + } + catch { + errors ??= []; + errors.push('The "browsers" option requires the "@vitest/browser" package to be installed within the project.' + + ' Please install this package and rerun the test command.'); + } + const provider = findBrowserProvider(projectResolver); + if (!provider) { + errors ??= []; + errors.push('The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' + + ' Please install one of these packages and rerun the test command.'); + } + // Vitest current requires the playwright browser provider to use the inspect-brk option used by "debug" + if (debug && provider !== 'playwright') { + errors ??= []; + errors.push('Debugging browser mode tests currently requires the use of "playwright".' + + ' Please install this package and rerun the test command.'); + } + if (errors) { + return { errors }; + } + const browser = { + enabled: true, + provider, + instances: browsers.map((browserName) => ({ + browser: browserName, + })), + }; + return { browser }; +} diff --git a/src/builders/unit-test/karma-bridge.js b/src/builders/unit-test/karma-bridge.js index f048d510..36a4e996 100644 --- a/src/builders/unit-test/karma-bridge.js +++ b/src/builders/unit-test/karma-bridge.js @@ -42,6 +42,9 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", { value: true }); exports.useKarmaBuilder = useKarmaBuilder; async function useKarmaBuilder(context, unitTestOptions) { + if (unitTestOptions.debug) { + context.logger.warn('The "karma" test runner does not support the "debug" option. The option will be ignored.'); + } const buildTargetOptions = (await context.validateOptions(await context.getTargetOptions(unitTestOptions.buildTarget), await context.getBuilderNameForTarget(unitTestOptions.buildTarget))); const options = { tsConfig: unitTestOptions.tsConfig, diff --git a/src/builders/unit-test/options.d.ts b/src/builders/unit-test/options.d.ts index 14b0b853..69715766 100644 --- a/src/builders/unit-test/options.d.ts +++ b/src/builders/unit-test/options.d.ts @@ -23,4 +23,6 @@ export declare function normalizeOptions(context: BuilderContext, projectName: s reporters: string[] | undefined; browsers: string[] | undefined; watch: boolean; + debug: boolean; + providersFile: string | undefined; }>; diff --git a/src/builders/unit-test/options.js b/src/builders/unit-test/options.js index b81176b3..75437dbf 100644 --- a/src/builders/unit-test/options.js +++ b/src/builders/unit-test/options.js @@ -15,6 +15,7 @@ const architect_1 = require("@angular-devkit/architect"); const node_path_1 = __importDefault(require("node:path")); const normalize_cache_1 = require("../../utils/normalize-cache"); const project_metadata_1 = require("../../utils/project-metadata"); +const tty_1 = require("../../utils/tty"); async function normalizeOptions(context, projectName, options) { // Setup base paths based on workspace root and project information const workspaceRoot = context.workspaceRoot; @@ -43,20 +44,8 @@ async function normalizeOptions(context, projectName, options) { tsConfig, reporters, browsers, - // TODO: Implement watch support - watch: false, + watch: options.watch ?? (0, tty_1.isTTY)(), + debug: options.debug ?? false, + providersFile: options.providersFile && node_path_1.default.join(workspaceRoot, options.providersFile), }; } -/** - * Normalize a directory path string. - * Currently only removes a trailing slash if present. - * @param path A path string. - * @returns A normalized path string. - */ -function normalizeDirectoryPath(path) { - const last = path[path.length - 1]; - if (last === '/' || last === '\\') { - return path.slice(0, -1); - } - return path; -} diff --git a/src/builders/unit-test/schema.d.ts b/src/builders/unit-test/schema.d.ts index 44100590..cacb6c80 100644 --- a/src/builders/unit-test/schema.d.ts +++ b/src/builders/unit-test/schema.d.ts @@ -21,6 +21,10 @@ export type Schema = { * Globs to exclude from code coverage. */ codeCoverageExclude?: string[]; + /** + * Initialize the test runner to support using the Node Inspector for test debugging. + */ + debug?: boolean; /** * Globs of files to exclude, relative to the project root. */ @@ -34,6 +38,11 @@ export type Schema = { * instead. */ include?: string[]; + /** + * TypeScript file that exports an array of Angular providers to use during test execution. + * The array must be a default export. + */ + providersFile?: string; /** * Test runner reporters to use. Directly passed to the test runner. */ diff --git a/src/builders/unit-test/schema.json b/src/builders/unit-test/schema.json index 223aa149..764a751a 100644 --- a/src/builders/unit-test/schema.json +++ b/src/builders/unit-test/schema.json @@ -46,6 +46,11 @@ "type": "boolean", "description": "Run build when files change." }, + "debug": { + "type": "boolean", + "description": "Initialize the test runner to support using the Node Inspector for test debugging.", + "default": false + }, "codeCoverage": { "type": "boolean", "description": "Output a code coverage report.", @@ -65,6 +70,11 @@ "items": { "type": "string" } + }, + "providersFile": { + "type": "string", + "description": "TypeScript file that exports an array of Angular providers to use during test execution. The array must be a default export.", + "minLength": 1 } }, "additionalProperties": false, diff --git a/src/tools/angular/compilation/aot-compilation.js b/src/tools/angular/compilation/aot-compilation.js index 6cd382f4..5d491a91 100644 --- a/src/tools/angular/compilation/aot-compilation.js +++ b/src/tools/angular/compilation/aot-compilation.js @@ -121,10 +121,7 @@ class AotCompilation extends angular_compilation_1.AngularCompilation { const updateId = encodeURIComponent(`${host.getCanonicalFileName(relativePath)}@${node.name?.text}`); const updateText = angularCompiler.emitHmrUpdateModule(node); // If compiler cannot generate an update for the component, prevent template updates. - // Also prevent template updates if $localize is directly present which also currently - // prevents a template update at runtime. - // TODO: Support localized template update modules and remove this check. - if (updateText === null || updateText.includes('$localize')) { + if (updateText === null) { // Build is needed if a template cannot be updated templateUpdates = undefined; break; diff --git a/src/tools/esbuild/angular/compiler-plugin.d.ts b/src/tools/esbuild/angular/compiler-plugin.d.ts index f7484597..34efbb1a 100644 --- a/src/tools/esbuild/angular/compiler-plugin.d.ts +++ b/src/tools/esbuild/angular/compiler-plugin.d.ts @@ -14,6 +14,12 @@ export interface CompilerPluginOptions { sourcemap: boolean | 'external'; tsconfig: string; jit?: boolean; + /** + * Include class metadata and JIT information in built code. + * The Angular TestBed APIs require additional metadata for the Angular aspects of the application + * such as Components, Modules, Pipes, etc. + * TestBed may also leverage JIT capabilities during testing (e.g., overrideComponent). + */ includeTestMetadata?: boolean; advancedOptimizations?: boolean; thirdPartySourcemaps?: boolean; diff --git a/src/tools/esbuild/angular/compiler-plugin.js b/src/tools/esbuild/angular/compiler-plugin.js index ee0c31b9..675ae157 100644 --- a/src/tools/esbuild/angular/compiler-plugin.js +++ b/src/tools/esbuild/angular/compiler-plugin.js @@ -89,7 +89,7 @@ function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBun sourcemap: !!pluginOptions.sourcemap, thirdPartySourcemaps: pluginOptions.thirdPartySourcemaps, advancedOptimizations: pluginOptions.advancedOptimizations, - jit: pluginOptions.jit, + jit: pluginOptions.jit || pluginOptions.includeTestMetadata, }, environment_options_1.maxWorkers, cacheStore?.createCache('jstransformer')); // Setup defines based on the values used by the Angular compiler-cli build.initialOptions.define ??= {}; @@ -553,6 +553,7 @@ function createCompilerOptionsTransformer(setupWarnings, pluginOptions, preserve externalRuntimeStyles: pluginOptions.externalRuntimeStyles, _enableHmr: !!pluginOptions.templateUpdates, supportTestBed: !!pluginOptions.includeTestMetadata, + supportJitMode: !!pluginOptions.includeTestMetadata, }; }; } diff --git a/src/tools/esbuild/angular/diagnostics.js b/src/tools/esbuild/angular/diagnostics.js index f3b5ac19..192c513a 100644 --- a/src/tools/esbuild/angular/diagnostics.js +++ b/src/tools/esbuild/angular/diagnostics.js @@ -55,9 +55,12 @@ function convertTypeScriptDiagnosticInfo(typescript, info, textPrefix) { function convertTypeScriptDiagnostic(typescript, diagnostic) { let codePrefix = 'TS'; let code = `${diagnostic.code}`; - if (diagnostic.source === 'ngtsc') { + // Custom ngtsc diagnostics are prefixed with -99 which isn't a valid TypeScript diagnostic code. + // Strip it and mark the diagnostic as coming from Angular. Note that we can't rely on + // `diagnostic.source`, because it isn't always produced. This is identical to: + // https://github.com/angular/angular/blob/main/packages/compiler-cli/src/ngtsc/diagnostics/src/util.ts + if (code.startsWith('-99')) { codePrefix = 'NG'; - // Remove `-99` Angular prefix from diagnostic code code = code.slice(3); } const message = convertTypeScriptDiagnosticInfo(typescript, diagnostic, `${codePrefix}${code}: `); diff --git a/src/tools/esbuild/bundler-context.js b/src/tools/esbuild/bundler-context.js index 08654c33..4f07c47a 100644 --- a/src/tools/esbuild/bundler-context.js +++ b/src/tools/esbuild/bundler-context.js @@ -13,6 +13,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.BundlerContext = exports.BuildOutputFileType = void 0; const esbuild_1 = require("esbuild"); const node_assert_1 = __importDefault(require("node:assert")); +const node_module_1 = require("node:module"); const node_path_1 = require("node:path"); const load_result_cache_1 = require("./load-result-cache"); const utils_1 = require("./utils"); @@ -198,10 +199,11 @@ class BundlerContext { if (this.incremental) { // Add input files except virtual angular files which do not exist on disk for (const input of Object.keys(result.metafile.inputs)) { - if (!isInternalAngularFile(input)) { - // input file paths are always relative to the workspace root - this.watchFiles.add((0, node_path_1.join)(this.workspaceRoot, input)); + if (isInternalAngularFile(input) || isInternalBundlerFile(input)) { + continue; } + // Input file paths are always relative to the workspace root + this.watchFiles.add((0, node_path_1.join)(this.workspaceRoot, input)); } } // Return if the build encountered any errors @@ -279,6 +281,7 @@ class BundlerContext { for (const { external, kind, path } of imports) { if (!external || utils_1.SERVER_GENERATED_EXTERNALS.has(path) || + isInternalAngularFile(path) || (kind !== 'import-statement' && kind !== 'dynamic-import' && kind !== 'require-call')) { continue; } @@ -380,3 +383,16 @@ exports.BundlerContext = BundlerContext; function isInternalAngularFile(file) { return file.startsWith('angular:'); } +function isInternalBundlerFile(file) { + // Bundler virtual files such as "" or "" + if (file[0] === '<' && file.at(-1) === '>') { + return true; + } + const DISABLED_BUILTIN = '(disabled):'; + // Disabled node builtins such as "/some/path/(disabled):fs" + const disabledIndex = file.indexOf(DISABLED_BUILTIN); + if (disabledIndex >= 0) { + return node_module_1.builtinModules.includes(file.slice(disabledIndex + DISABLED_BUILTIN.length)); + } + return false; +} diff --git a/src/tools/esbuild/i18n-inliner-worker.d.ts b/src/tools/esbuild/i18n-inliner-worker.d.ts index 1d69bb53..f660a061 100644 --- a/src/tools/esbuild/i18n-inliner-worker.d.ts +++ b/src/tools/esbuild/i18n-inliner-worker.d.ts @@ -8,7 +8,7 @@ /** * The options passed to the inliner for each file request */ -interface InlineRequest { +interface InlineFileRequest { /** * The filename that should be processed. The data for the file is provided to the Worker * during Worker initialization. @@ -23,14 +23,35 @@ interface InlineRequest { */ translation?: Record; } +/** + * The options passed to the inliner for each code request + */ +interface InlineCodeRequest { + /** + * The code that should be processed. + */ + code: string; + /** + * The filename to use in error and warning messages for the provided code. + */ + filename: string; + /** + * The locale specifier that should be used during the inlining process of the file. + */ + locale: string; + /** + * The translation messages for the locale that should be used during the inlining process of the file. + */ + translation?: Record; +} /** * Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage. * This function is the main entry for the Worker's action that is called by the worker pool. * * @param request An InlineRequest object representing the options for inlining - * @returns An array containing the inlined file and optional map content. + * @returns An object containing the inlined file and optional map content. */ -export default function inlineLocale(request: InlineRequest): Promise<{ +export default function inlineFile(request: InlineFileRequest): Promise<{ file: string; code: string; map: string | undefined; @@ -39,4 +60,18 @@ export default function inlineLocale(request: InlineRequest): Promise<{ message: string; }[]; }>; +/** + * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. + * This function is a secondary entry primarily for use with component HMR update modules. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An object containing the inlined code. + */ +export declare function inlineCode(request: InlineCodeRequest): Promise<{ + output: string; + messages: { + type: "warning" | "error"; + message: string; + }[]; +}>; export {}; diff --git a/src/tools/esbuild/i18n-inliner-worker.js b/src/tools/esbuild/i18n-inliner-worker.js index d6079517..311b2c0a 100644 --- a/src/tools/esbuild/i18n-inliner-worker.js +++ b/src/tools/esbuild/i18n-inliner-worker.js @@ -10,7 +10,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = inlineLocale; +exports.default = inlineFile; +exports.inlineCode = inlineCode; const remapping_1 = __importDefault(require("@ampproject/remapping")); const core_1 = require("@babel/core"); const node_assert_1 = __importDefault(require("node:assert")); @@ -25,9 +26,9 @@ const { files, missingTranslation, shouldOptimize } = (node_worker_threads_1.wor * This function is the main entry for the Worker's action that is called by the worker pool. * * @param request An InlineRequest object representing the options for inlining - * @returns An array containing the inlined file and optional map content. + * @returns An object containing the inlined file and optional map content. */ -async function inlineLocale(request) { +async function inlineFile(request) { const data = files.get(request.filename); (0, node_assert_1.default)(data !== undefined, `Invalid inline request for file '${request.filename}'.`); const code = await data.text(); @@ -40,6 +41,20 @@ async function inlineLocale(request) { messages: result.diagnostics.messages, }; } +/** + * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. + * This function is a secondary entry primarily for use with component HMR update modules. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An object containing the inlined code. + */ +async function inlineCode(request) { + const result = await transformWithBabel(request.code, undefined, request); + return { + output: result.code, + messages: result.diagnostics.messages, + }; +} /** * Cached instance of the `@angular/localize/tools` module. * This is used to remove the need to repeatedly import the module per file translation. diff --git a/src/tools/esbuild/i18n-inliner.d.ts b/src/tools/esbuild/i18n-inliner.d.ts index 47c2d11d..9b44032d 100644 --- a/src/tools/esbuild/i18n-inliner.d.ts +++ b/src/tools/esbuild/i18n-inliner.d.ts @@ -38,6 +38,11 @@ export declare class I18nInliner { errors: string[]; warnings: string[]; }>; + inlineTemplateUpdate(locale: string, translation: Record | undefined, templateCode: string, templateId: string): Promise<{ + code: string; + errors: string[]; + warnings: string[]; + }>; /** * Stops all active transformation tasks and shuts down all workers. * @returns A void promise that resolves when closing is complete. diff --git a/src/tools/esbuild/i18n-inliner.js b/src/tools/esbuild/i18n-inliner.js index b4e7b167..7c25cc78 100644 --- a/src/tools/esbuild/i18n-inliner.js +++ b/src/tools/esbuild/i18n-inliner.js @@ -201,6 +201,32 @@ class I18nInliner { warnings, }; } + async inlineTemplateUpdate(locale, translation, templateCode, templateId) { + const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD); + if (!hasLocalize) { + return { + code: templateCode, + errors: [], + warnings: [], + }; + } + const { output, messages } = await this.#workerPool.run({ code: templateCode, filename: templateId, locale, translation }, { name: 'inlineCode' }); + const errors = []; + const warnings = []; + for (const message of messages) { + if (message.type === 'error') { + errors.push(message.message); + } + else { + warnings.push(message.message); + } + } + return { + code: output, + errors, + warnings, + }; + } /** * Stops all active transformation tasks and shuts down all workers. * @returns A void promise that resolves when closing is complete. diff --git a/src/tools/esbuild/javascript-transformer-worker.js b/src/tools/esbuild/javascript-transformer-worker.js index d60c62fa..a11ec6f9 100644 --- a/src/tools/esbuild/javascript-transformer-worker.js +++ b/src/tools/esbuild/javascript-transformer-worker.js @@ -51,6 +51,13 @@ const piscina_1 = __importDefault(require("piscina")); const load_esm_1 = require("../../utils/load-esm"); const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); +/** + * The function name prefix for all Angular partial compilation functions. + * Used to determine if linking of a JavaScript file is required. + * If any additional declarations are added or otherwise changed in the linker, + * the names MUST begin with this prefix. + */ +const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare'; async function transformJavaScript(request) { const { filename, data, ...options } = request; const textData = typeof data === 'string' ? data : textDecoder.decode(data); @@ -62,10 +69,6 @@ async function transformJavaScript(request) { * Cached instance of the compiler-cli linker's createEs2015LinkerPlugin function. */ let linkerPluginCreator; -/** - * Cached instance of the compiler-cli linker's needsLinking function. - */ -let needsLinking; async function transformWithBabel(filename, data, options) { const shouldLink = !options.skipLinker && (await requiresLinking(filename, data)); const useInputSourcemap = options.sourcemap && @@ -120,14 +123,10 @@ async function requiresLinking(path, source) { if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) { return false; } - if (!needsLinking) { - // Load ESM `@angular/compiler-cli/linker` using the TypeScript dynamic import workaround. - // Once TypeScript provides support for keeping the dynamic import this workaround can be - // changed to a direct dynamic import. - const linkerModule = await (0, load_esm_1.loadEsmModule)('@angular/compiler-cli/linker'); - needsLinking = linkerModule.needsLinking; - } - return needsLinking(path, source); + // Check if the source code includes one of the declaration functions. + // There is a low chance of a false positive but the names are fairly unique + // and the result would be an unnecessary no-op additional plugin pass. + return source.includes(LINKER_DECLARATION_PREFIX); } async function createLinkerPlugin(options) { linkerPluginCreator ??= (await (0, load_esm_1.loadEsmModule)('@angular/compiler-cli/linker/babel')).createEs2015LinkerPlugin; diff --git a/src/tools/esbuild/javascript-transformer.js b/src/tools/esbuild/javascript-transformer.js index c74a7ebc..6e823d90 100644 --- a/src/tools/esbuild/javascript-transformer.js +++ b/src/tools/esbuild/javascript-transformer.js @@ -39,6 +39,9 @@ class JavaScriptTransformer { this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8'); } #ensureWorkerPool() { + if (this.#workerPool) { + return this.#workerPool; + } const workerPoolOptions = { filename: require.resolve('./javascript-transformer-worker'), maxThreads: this.maxThreads, @@ -48,7 +51,7 @@ class JavaScriptTransformer { if (process.execArgv.length !== filteredExecArgv.length) { workerPoolOptions.execArgv = filteredExecArgv; } - this.#workerPool ??= new worker_pool_1.WorkerPool(workerPoolOptions); + this.#workerPool = new worker_pool_1.WorkerPool(workerPoolOptions); return this.#workerPool; } /** diff --git a/src/tools/esbuild/utils.js b/src/tools/esbuild/utils.js index d54ddf26..d2d4761b 100644 --- a/src/tools/esbuild/utils.js +++ b/src/tools/esbuild/utils.js @@ -315,7 +315,7 @@ function transformSupportedBrowsersToTargets(supportedBrowsers) { } return transformed; } -const SUPPORTED_NODE_VERSIONS = '^20.11.1 || >=22.11.0'; +const SUPPORTED_NODE_VERSIONS = '^20.19.0 || ^22.12.0 || >=24.0.0'; /** * Transform supported Node.js versions to esbuild target. * @see https://esbuild.github.io/api/#target diff --git a/src/utils/i18n-options.d.ts b/src/utils/i18n-options.d.ts index 364132b0..481a3221 100644 --- a/src/utils/i18n-options.d.ts +++ b/src/utils/i18n-options.d.ts @@ -5,7 +5,6 @@ * 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 { DiagnosticHandlingStrategy } from '@angular/localize/tools'; import type { TranslationLoader } from './load-translations'; export interface LocaleDescription { files: { @@ -25,7 +24,6 @@ export interface I18nOptions { flatOutput?: boolean; readonly shouldInline: boolean; hasDefinedSourceLocale?: boolean; - i18nDuplicateTranslation?: DiagnosticHandlingStrategy; } export declare function createI18nOptions(projectMetadata: { i18n?: unknown; diff --git a/src/utils/load-proxy-config.js b/src/utils/load-proxy-config.js index f8a7088a..2f89d612 100644 --- a/src/utils/load-proxy-config.js +++ b/src/utils/load-proxy-config.js @@ -78,8 +78,7 @@ async function loadProxyConfiguration(root, proxyConfig) { // Load the ESM configuration file using the TypeScript dynamic import workaround. // Once TypeScript provides support for keeping the dynamic import this workaround can be // changed to a direct dynamic import. - proxyConfiguration = (await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath))) - .default; + proxyConfiguration = await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath)); break; case '.cjs': proxyConfiguration = require(proxyPath); @@ -93,17 +92,19 @@ async function loadProxyConfiguration(root, proxyConfig) { } catch (e) { (0, error_1.assertIsError)(e); - if (e.code === 'ERR_REQUIRE_ESM') { + if (e.code === 'ERR_REQUIRE_ESM' || e.code === 'ERR_REQUIRE_ASYNC_MODULE') { // Load the ESM configuration file using the TypeScript dynamic import workaround. // Once TypeScript provides support for keeping the dynamic import this workaround can be // changed to a direct dynamic import. - proxyConfiguration = (await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath))) - .default; + proxyConfiguration = await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath)); break; } throw e; } } + if ('default' in proxyConfiguration) { + proxyConfiguration = proxyConfiguration.default; + } return normalizeProxyConfiguration(proxyConfiguration); } /** diff --git a/src/utils/normalize-cache.js b/src/utils/normalize-cache.js index 3e9d7776..b0ddee8a 100644 --- a/src/utils/normalize-cache.js +++ b/src/utils/normalize-cache.js @@ -10,7 +10,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeCacheOptions = normalizeCacheOptions; const node_path_1 = require("node:path"); /** Version placeholder is replaced during the build process with actual package version */ -const VERSION = '20.0.0-next.8+sha-bbb08d6'; +const VERSION = '20.0.6+sha-82cf0cb'; function hasCacheMetadata(value) { return (!!value && typeof value === 'object' && diff --git a/src/utils/server-rendering/manifest.js b/src/utils/server-rendering/manifest.js index a96c34bf..a93689bc 100644 --- a/src/utils/server-rendering/manifest.js +++ b/src/utils/server-rendering/manifest.js @@ -161,22 +161,12 @@ function generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath = '' if (!entryPoint || exports?.length < 1 || !fileName.endsWith('.js')) { continue; } - const importedPaths = [ - { - path: `${publicPath}${fileName}`, - dynamicImport: false, - }, - ]; + const importedPaths = [`${publicPath}${fileName}`]; for (const { kind, external, path } of imports) { - if (external || - initialFiles.has(path) || - (kind !== 'dynamic-import' && kind !== 'import-statement')) { + if (external || initialFiles.has(path) || kind !== 'import-statement') { continue; } - importedPaths.push({ - path: `${publicPath}${path}`, - dynamicImport: kind === 'dynamic-import', - }); + importedPaths.push(`${publicPath}${path}`); } entryPointToBundles[entryPoint] = importedPaths; } diff --git a/src/utils/version.js b/src/utils/version.js index 1537753a..ad05586d 100644 --- a/src/utils/version.js +++ b/src/utils/version.js @@ -28,7 +28,7 @@ function assertCompatibleAngularVersion(projectRoot) { 'This likely indicates a corrupted local installation. Please try reinstalling your packages.'); process.exit(2); } - const supportedAngularSemver = '^20.0.0 || ^20.0.0-next.0'; + const supportedAngularSemver = '^20.0.0'; if (angularPkgJson['version'] === '0.0.0' || supportedAngularSemver.startsWith('0.0.0')) { // Internal CLI and FW testing version. return; diff --git a/src/utils/worker-pool.js b/src/utils/worker-pool.js index 06fdcc74..be23e426 100644 --- a/src/utils/worker-pool.js +++ b/src/utils/worker-pool.js @@ -14,10 +14,10 @@ class WorkerPool extends piscina_1.Piscina { constructor(options) { const piscinaOptions = { minThreads: 1, - idleTimeout: 1000, + idleTimeout: 4_000, // Web containers do not support transferable objects with receiveOnMessagePort which // is used when the Atomics based wait loop is enable. - useAtomics: !process.versions.webcontainer, + atomics: process.versions.webcontainer ? 'disabled' : 'sync', recordTiming: false, ...options, }; diff --git a/uniqueId b/uniqueId index a2ced7e0..9aa1671c 100644 --- a/uniqueId +++ b/uniqueId @@ -1 +1 @@ -Wed Apr 30 2025 18:45:54 GMT+0000 (Coordinated Universal Time) \ No newline at end of file +Wed Jul 09 2025 15:51:24 GMT+0000 (Coordinated Universal Time) \ No newline at end of file