diff --git a/packages/angular/build/src/builders/application/tests/options/i18n-output-hashing_spec.ts b/packages/angular/build/src/builders/application/tests/options/i18n-output-hashing_spec.ts new file mode 100644 index 000000000000..9ea8744a99a7 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/i18n-output-hashing_spec.ts @@ -0,0 +1,78 @@ +/** + * @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 { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('i18n output hashing', () => { + beforeEach(() => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + locales: { + 'fr': 'src/locales/messages.fr.xlf', + }, + }, + }); + }); + + it('should not include a global i18n hash footer in localized output files', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + outputHashing: OutputHashing.None, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', TRANSLATION_FILE_CONTENT); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Verify that the main JS output file does not contain a global i18n footer hash comment. + // Previously, all JS files included a `/**i18n:*/` footer computed from ALL + // translation files, causing all chunk hashes to change whenever any translation + // changed (issue #30675). + harness + .expectFile('dist/browser/fr/main.js') + .content.not.toMatch(/\/\*\*i18n:[0-9a-f]{64}\*\//); + }); + }); +}); + +const TRANSLATION_FILE_CONTENT = ` + + + + + + Bonjour ! + + src/app/app.component.html + 2,3 + + An introduction header for this sample + + + + +`; 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 c3f542e1bdfb..3b5afc2a9348 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -8,7 +8,6 @@ import type { BuildOptions, Plugin } from 'esbuild'; import assert from 'node:assert'; -import { createHash } from 'node:crypto'; import { extname, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { Platform } from '../../builders/application/schema'; @@ -547,26 +546,10 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu jit, loaderExtensions, jsonLogs, - i18nOptions, customConditions, frameworkVersion, } = options; - // Ensure unique hashes for i18n translation changes when using post-process inlining. - // This hash value is added as a footer to each file and ensures that the output file names (with hashes) - // change when translation files have changed. If this is not done the post processed files may have - // different content but would retain identical production file names which would lead to browser caching problems. - let footer; - if (i18nOptions.shouldInline) { - // Update file hashes to include translation file content - const i18nHash = Object.values(i18nOptions.locales).reduce( - (data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'), - '', - ); - - footer = { js: `/**i18n:${createHash('sha256').update(i18nHash).digest('hex')}*/` }; - } - // Core conditions that are always included const conditions = [ // Required to support rxjs 7.x which will use es5 code if this condition is not present @@ -653,7 +636,6 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu 'ngHmrMode': options.templateUpdates ? 'true' : 'false', }, loader: loaderExtensions, - footer, plugins, }; } diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index fcb439b84c5c..3f7d7a476439 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -179,28 +179,88 @@ export class I18nInliner { // Convert raw results to output file objects and include all unmodified files const errors: string[] = []; const warnings: string[] = []; - const outputFiles = [ - ...rawResults.flatMap(({ file, code, map, messages }) => { - const type = this.#localizeFiles.get(file)?.type; - assert(type !== undefined, 'localized file should always have a type' + file); - - const resultFiles = [createOutputFile(file, code, type)]; - if (map) { - resultFiles.push(createOutputFile(file + '.map', map, type)); + + // Build a map of old filename to new filename for files whose content changed + // during inlining. This ensures output filenames reflect the actual inlined + // content rather than using stale hashes from the pre-inlining esbuild build. + // Without this, all localized files would share identical filenames across builds + // even when their translated content differs, leading to browser caching issues. + const filenameRenameMap = new Map(); + + // Regex to extract the hash portion from filenames like "chunk-HASH.js" or "name-HASH.js" + const hashPattern = /^(.+)-([A-Z0-9]{8})(\.[a-z]+)$/; + + const inlinedFiles: Array<{ + file: string; + code: string; + map: string | undefined; + type: BuildOutputFileType; + }> = []; + + for (const { file, code, map, messages } of rawResults) { + const type = this.#localizeFiles.get(file)?.type; + assert(type !== undefined, 'localized file should always have a type' + file); + + for (const message of messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); } + } - for (const message of messages) { - if (message.type === 'error') { - errors.push(message.message); - } else { - warnings.push(message.message); + // Check if the file content actually changed during inlining by comparing + // the inlined code hash against the original file's hash. + const originalFile = this.#localizeFiles.get(file); + const originalHash = originalFile?.hash; + const newContentHash = createHash('sha256').update(code).digest('hex'); + + if (originalHash !== newContentHash) { + // Content changed during inlining - compute a new filename hash + const match = file.match(hashPattern); + if (match) { + const [, prefix, oldHash, ext] = match; + // Generate a new 8-character uppercase alphanumeric hash from the inlined content. + // Uses base-36 encoding to match esbuild's hash format (A-Z, 0-9). + const hashBytes = createHash('sha256').update(code).digest(); + const hashValue = hashBytes.readBigUInt64BE(0); + const newHash = hashValue.toString(36).slice(0, 8).toUpperCase().padStart(8, '0'); + if (oldHash !== newHash) { + // Use the base filename (without directory) for replacement in file content + const baseName = prefix.includes('/') ? prefix.slice(prefix.lastIndexOf('/') + 1) : prefix; + const oldBaseName = `${baseName}-${oldHash}`; + const newBaseName = `${baseName}-${newHash}`; + filenameRenameMap.set(oldBaseName, newBaseName); } } + } + + inlinedFiles.push({ file, code, map, type }); + } - return resultFiles; - }), - ...this.#unmodifiedFiles.map((file) => file.clone()), - ]; + // Apply filename renames to file paths and content for all output files + const outputFiles: BuildOutputFile[] = []; + for (const { file, code, map, type } of inlinedFiles) { + const updatedPath = applyFilenameRenames(file, filenameRenameMap); + const updatedCode = applyFilenameRenames(code, filenameRenameMap); + outputFiles.push(createOutputFile(updatedPath, updatedCode, type)); + if (map) { + const updatedMap = applyFilenameRenames(map, filenameRenameMap); + outputFiles.push(createOutputFile(updatedPath + '.map', updatedMap, type)); + } + } + + // Also apply filename renames to unmodified files (they may reference renamed chunks) + for (const file of this.#unmodifiedFiles) { + const clone = file.clone(); + if (filenameRenameMap.size > 0) { + const updatedPath = applyFilenameRenames(clone.path, filenameRenameMap); + const updatedText = applyFilenameRenames(clone.text, filenameRenameMap); + outputFiles.push(createOutputFile(updatedPath, updatedText, clone.type)); + } else { + outputFiles.push(clone); + } + } return { outputFiles, @@ -288,3 +348,22 @@ export class I18nInliner { } } } + +/** + * Applies filename renames to a string by replacing all occurrences of old filenames with new ones. + * This is used to update file paths and file contents (e.g., dynamic import references like + * `import("./chunk-OLDHASH.js")`) after i18n inlining has changed file content hashes. + * Uses full base filenames (e.g., "chunk-ABCD1234") rather than bare hashes to minimize + * the risk of accidental replacements in unrelated content. + */ +function applyFilenameRenames(content: string, renameMap: Map): string { + if (renameMap.size === 0) { + return content; + } + + for (const [oldName, newName] of renameMap) { + content = content.replaceAll(oldName, newName); + } + + return content; +}