From f55cc706f4a2a3df5fe9efd4f0c2ccef294d0d5d Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Sat, 28 Mar 2026 02:07:28 +0530 Subject: [PATCH] fix(@angular/build): stabilize chunk hashes during i18n inlining Previously, a global SHA-256 hash of ALL translation files was appended as a footer comment to every JavaScript output file. This caused esbuild to generate different content hashes for ALL chunks whenever ANY translation changed, even if the actual code in those chunks was unaffected. This led to complete cache invalidation on every deploy for i18n applications. The fix removes the global i18n footer and instead rehashes output filenames after i18n inlining based on the actual inlined content of each file. Only files whose content actually changed during translation inlining will receive new filename hashes. Cross-chunk import references are also updated to reflect the new filenames. Fixes #30675 --- .../tests/options/i18n-output-hashing_spec.ts | 78 ++++++++++++ .../tools/esbuild/application-code-bundle.ts | 18 --- .../build/src/tools/esbuild/i18n-inliner.ts | 113 +++++++++++++++--- 3 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/options/i18n-output-hashing_spec.ts 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; +}