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;
+}