From 23878ffefe7e414339577bb32a586db890edc347 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:56:08 -0400 Subject: [PATCH] refactor(@angular/build): use translation integrity for i18n inlining cache keys Avoid full JSON serialization and hashing of the in-memory translation dictionary for each locale when computing persistent cache keys. Instead, use the combined translation file integrity hashes and the installed `@angular/localize` package version when available, falling back to the in-memory translation object if integrity is absent. --- .../build/src/builders/application/i18n.ts | 25 +++++++++++++- .../build/src/tools/esbuild/i18n-inliner.ts | 11 ++++++- .../src/tools/esbuild/i18n-inliner_spec.ts | 33 +++++++++++++++++++ .../angular/build/src/utils/i18n-options.ts | 1 + 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index c83f1a29a30a..137336497885 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -8,6 +8,7 @@ import { BuilderContext } from '@angular-devkit/architect'; import type { Metafile } from 'esbuild'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ExecutionResult, @@ -18,6 +19,7 @@ import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; import { maxWorkers } from '../../utils/environment-options'; import { loadTranslations } from '../../utils/i18n-options'; import { createTranslationLoader } from '../../utils/load-translations'; +import { createProjectResolver } from '../../utils/resolve-project'; import { executePostBundleSteps } from './execute-post-bundle'; import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; @@ -48,6 +50,7 @@ export async function inlineI18n( outputFiles: executionResult.outputFiles, shouldOptimize: optimizationOptions.scripts, persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + localizeVersion: i18nOptions.localizeVersion, }, maxWorkers, ); @@ -72,10 +75,21 @@ export async function inlineI18n( try { for (const locale of i18nOptions.inlineLocales) { + const localeDescription = i18nOptions.locales[locale]; + let translationIntegrity: string | undefined = ''; + for (const file of localeDescription.files) { + if (!file.integrity) { + translationIntegrity = undefined; + break; + } + translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity; + } + // A locale specific set of files is returned from the inliner. const localeInlineResult = await inliner.inlineForLocale( locale, - i18nOptions.locales[locale].translation, + localeDescription.translation, + translationIntegrity, ); const localeOutputFiles = localeInlineResult.outputFiles; inlineResult.errors.push(...localeInlineResult.errors); @@ -176,6 +190,15 @@ export async function loadActiveTranslations( context: BuilderContext, i18n: NormalizedApplicationBuildOptions['i18nOptions'], ) { + if (!i18n.localizeVersion) { + try { + const projectResolve = createProjectResolver(context.workspaceRoot); + const manifestPath = projectResolve('@angular/localize/package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version?: string }; + i18n.localizeVersion = manifest.version; + } catch {} + } + // Load locale data and translations (if present) let loader; for (const [locale, desc] of Object.entries(i18n.locales)) { diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 678511612afb..a65d28516cce 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -42,6 +42,7 @@ export interface I18nInlinerOptions { outputFiles: BuildOutputFile[]; shouldOptimize?: boolean; persistentCachePath?: string; + localizeVersion?: string; } /** @@ -131,11 +132,13 @@ export class I18nInliner { * of the localize function keyword. * @param locale The string representing the locale to inline. * @param translation The translation messages to use when inlining. + * @param translationIntegrity An optional integrity value for the translation messages to use for caching. * @returns A promise that resolves to an array of OutputFiles representing a translated result. */ async inlineForLocale( locale: string, translation: Record | undefined, + translationIntegrity?: string, ): Promise<{ outputFiles: BuildOutputFile[]; errors: string[]; warnings: string[] }> { await this.initCache(); @@ -161,7 +164,13 @@ export class I18nInliner { // of bytes. Hashing the options directly would re-hash the full set of messages, which // can be several megabytes, once for every file. fileCacheKeyBase ??= calculateHash( - JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }), + JSON.stringify({ + locale, + translation: translationIntegrity ?? translation, + missingTranslation, + shouldOptimize, + localizeVersion: this.options.localizeVersion, + }), ); // NOTE: If additional options are added, this may need to be updated. diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts index 609774ced508..94f390e2334c 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -307,4 +307,37 @@ describe('I18nInliner', () => { 'Malformed escape sequence in $localize template literal in file "main.js".', ]); }); + + it('inlines the translations of a locale when translationIntegrity is provided', async () => { + const { outputFiles, errors, warnings } = await createInliner([ + browserFile('main.js', GREETING_SOURCE), + ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }, 'sha256-test-integrity'); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of a locale when localizeVersion is configured in options', async () => { + inliner = new I18nInliner( + { + missingTranslation: 'warning', + outputFiles: [browserFile('main.js', GREETING_SOURCE)], + localizeVersion: '20.2.0', + }, + 1, + ); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale( + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); }); diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts index 822683bef03d..6a288622d053 100644 --- a/packages/angular/build/src/utils/i18n-options.ts +++ b/packages/angular/build/src/utils/i18n-options.ts @@ -28,6 +28,7 @@ export interface I18nOptions { flatOutput?: boolean; readonly shouldInline: boolean; hasDefinedSourceLocale?: boolean; + localizeVersion?: string; } function normalizeTranslationFileOption(