Skip to content

Commit f55cc70

Browse files
committed
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
1 parent f1ed025 commit f55cc70

File tree

3 files changed

+174
-35
lines changed

3 files changed

+174
-35
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { OutputHashing } from '../../schema';
11+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
12+
13+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
14+
describe('i18n output hashing', () => {
15+
beforeEach(() => {
16+
harness.useProject('test', {
17+
root: '.',
18+
sourceRoot: 'src',
19+
cli: {
20+
cache: {
21+
enabled: false,
22+
},
23+
},
24+
i18n: {
25+
locales: {
26+
'fr': 'src/locales/messages.fr.xlf',
27+
},
28+
},
29+
});
30+
});
31+
32+
it('should not include a global i18n hash footer in localized output files', async () => {
33+
harness.useTarget('build', {
34+
...BASE_OPTIONS,
35+
localize: true,
36+
outputHashing: OutputHashing.None,
37+
});
38+
39+
await harness.writeFile(
40+
'src/app/app.component.html',
41+
`
42+
<p id="hello" i18n="An introduction header for this sample">Hello {{ title }}! </p>
43+
`,
44+
);
45+
46+
await harness.writeFile('src/locales/messages.fr.xlf', TRANSLATION_FILE_CONTENT);
47+
48+
const { result } = await harness.executeOnce();
49+
expect(result?.success).toBeTrue();
50+
51+
// Verify that the main JS output file does not contain a global i18n footer hash comment.
52+
// Previously, all JS files included a `/**i18n:<sha256>*/` footer computed from ALL
53+
// translation files, causing all chunk hashes to change whenever any translation
54+
// changed (issue #30675).
55+
harness
56+
.expectFile('dist/browser/fr/main.js')
57+
.content.not.toMatch(/\/\*\*i18n:[0-9a-f]{64}\*\//);
58+
});
59+
});
60+
});
61+
62+
const TRANSLATION_FILE_CONTENT = `
63+
<?xml version="1.0" encoding="UTF-8" ?>
64+
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
65+
<file target-language="fr" datatype="plaintext" original="ng2.template">
66+
<body>
67+
<trans-unit id="4286451273117902052" datatype="html">
68+
<target>Bonjour <x id="INTERPOLATION" equiv-text="{{ title }}"/>! </target>
69+
<context-group purpose="location">
70+
<context context-type="targetfile">src/app/app.component.html</context>
71+
<context context-type="linenumber">2,3</context>
72+
</context-group>
73+
<note priority="1" from="description">An introduction header for this sample</note>
74+
</trans-unit>
75+
</body>
76+
</file>
77+
</xliff>
78+
`;

packages/angular/build/src/tools/esbuild/application-code-bundle.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type { BuildOptions, Plugin } from 'esbuild';
1010
import assert from 'node:assert';
11-
import { createHash } from 'node:crypto';
1211
import { extname, relative } from 'node:path';
1312
import type { NormalizedApplicationBuildOptions } from '../../builders/application/options';
1413
import { Platform } from '../../builders/application/schema';
@@ -547,26 +546,10 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu
547546
jit,
548547
loaderExtensions,
549548
jsonLogs,
550-
i18nOptions,
551549
customConditions,
552550
frameworkVersion,
553551
} = options;
554552

555-
// Ensure unique hashes for i18n translation changes when using post-process inlining.
556-
// This hash value is added as a footer to each file and ensures that the output file names (with hashes)
557-
// change when translation files have changed. If this is not done the post processed files may have
558-
// different content but would retain identical production file names which would lead to browser caching problems.
559-
let footer;
560-
if (i18nOptions.shouldInline) {
561-
// Update file hashes to include translation file content
562-
const i18nHash = Object.values(i18nOptions.locales).reduce(
563-
(data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'),
564-
'',
565-
);
566-
567-
footer = { js: `/**i18n:${createHash('sha256').update(i18nHash).digest('hex')}*/` };
568-
}
569-
570553
// Core conditions that are always included
571554
const conditions = [
572555
// 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
653636
'ngHmrMode': options.templateUpdates ? 'true' : 'false',
654637
},
655638
loader: loaderExtensions,
656-
footer,
657639
plugins,
658640
};
659641
}

packages/angular/build/src/tools/esbuild/i18n-inliner.ts

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -179,28 +179,88 @@ export class I18nInliner {
179179
// Convert raw results to output file objects and include all unmodified files
180180
const errors: string[] = [];
181181
const warnings: string[] = [];
182-
const outputFiles = [
183-
...rawResults.flatMap(({ file, code, map, messages }) => {
184-
const type = this.#localizeFiles.get(file)?.type;
185-
assert(type !== undefined, 'localized file should always have a type' + file);
186-
187-
const resultFiles = [createOutputFile(file, code, type)];
188-
if (map) {
189-
resultFiles.push(createOutputFile(file + '.map', map, type));
182+
183+
// Build a map of old filename to new filename for files whose content changed
184+
// during inlining. This ensures output filenames reflect the actual inlined
185+
// content rather than using stale hashes from the pre-inlining esbuild build.
186+
// Without this, all localized files would share identical filenames across builds
187+
// even when their translated content differs, leading to browser caching issues.
188+
const filenameRenameMap = new Map<string, string>();
189+
190+
// Regex to extract the hash portion from filenames like "chunk-HASH.js" or "name-HASH.js"
191+
const hashPattern = /^(.+)-([A-Z0-9]{8})(\.[a-z]+)$/;
192+
193+
const inlinedFiles: Array<{
194+
file: string;
195+
code: string;
196+
map: string | undefined;
197+
type: BuildOutputFileType;
198+
}> = [];
199+
200+
for (const { file, code, map, messages } of rawResults) {
201+
const type = this.#localizeFiles.get(file)?.type;
202+
assert(type !== undefined, 'localized file should always have a type' + file);
203+
204+
for (const message of messages) {
205+
if (message.type === 'error') {
206+
errors.push(message.message);
207+
} else {
208+
warnings.push(message.message);
190209
}
210+
}
191211

192-
for (const message of messages) {
193-
if (message.type === 'error') {
194-
errors.push(message.message);
195-
} else {
196-
warnings.push(message.message);
212+
// Check if the file content actually changed during inlining by comparing
213+
// the inlined code hash against the original file's hash.
214+
const originalFile = this.#localizeFiles.get(file);
215+
const originalHash = originalFile?.hash;
216+
const newContentHash = createHash('sha256').update(code).digest('hex');
217+
218+
if (originalHash !== newContentHash) {
219+
// Content changed during inlining - compute a new filename hash
220+
const match = file.match(hashPattern);
221+
if (match) {
222+
const [, prefix, oldHash, ext] = match;
223+
// Generate a new 8-character uppercase alphanumeric hash from the inlined content.
224+
// Uses base-36 encoding to match esbuild's hash format (A-Z, 0-9).
225+
const hashBytes = createHash('sha256').update(code).digest();
226+
const hashValue = hashBytes.readBigUInt64BE(0);
227+
const newHash = hashValue.toString(36).slice(0, 8).toUpperCase().padStart(8, '0');
228+
if (oldHash !== newHash) {
229+
// Use the base filename (without directory) for replacement in file content
230+
const baseName = prefix.includes('/') ? prefix.slice(prefix.lastIndexOf('/') + 1) : prefix;
231+
const oldBaseName = `${baseName}-${oldHash}`;
232+
const newBaseName = `${baseName}-${newHash}`;
233+
filenameRenameMap.set(oldBaseName, newBaseName);
197234
}
198235
}
236+
}
237+
238+
inlinedFiles.push({ file, code, map, type });
239+
}
199240

200-
return resultFiles;
201-
}),
202-
...this.#unmodifiedFiles.map((file) => file.clone()),
203-
];
241+
// Apply filename renames to file paths and content for all output files
242+
const outputFiles: BuildOutputFile[] = [];
243+
for (const { file, code, map, type } of inlinedFiles) {
244+
const updatedPath = applyFilenameRenames(file, filenameRenameMap);
245+
const updatedCode = applyFilenameRenames(code, filenameRenameMap);
246+
outputFiles.push(createOutputFile(updatedPath, updatedCode, type));
247+
if (map) {
248+
const updatedMap = applyFilenameRenames(map, filenameRenameMap);
249+
outputFiles.push(createOutputFile(updatedPath + '.map', updatedMap, type));
250+
}
251+
}
252+
253+
// Also apply filename renames to unmodified files (they may reference renamed chunks)
254+
for (const file of this.#unmodifiedFiles) {
255+
const clone = file.clone();
256+
if (filenameRenameMap.size > 0) {
257+
const updatedPath = applyFilenameRenames(clone.path, filenameRenameMap);
258+
const updatedText = applyFilenameRenames(clone.text, filenameRenameMap);
259+
outputFiles.push(createOutputFile(updatedPath, updatedText, clone.type));
260+
} else {
261+
outputFiles.push(clone);
262+
}
263+
}
204264

205265
return {
206266
outputFiles,
@@ -288,3 +348,22 @@ export class I18nInliner {
288348
}
289349
}
290350
}
351+
352+
/**
353+
* Applies filename renames to a string by replacing all occurrences of old filenames with new ones.
354+
* This is used to update file paths and file contents (e.g., dynamic import references like
355+
* `import("./chunk-OLDHASH.js")`) after i18n inlining has changed file content hashes.
356+
* Uses full base filenames (e.g., "chunk-ABCD1234") rather than bare hashes to minimize
357+
* the risk of accidental replacements in unrelated content.
358+
*/
359+
function applyFilenameRenames(content: string, renameMap: Map<string, string>): string {
360+
if (renameMap.size === 0) {
361+
return content;
362+
}
363+
364+
for (const [oldName, newName] of renameMap) {
365+
content = content.replaceAll(oldName, newName);
366+
}
367+
368+
return content;
369+
}

0 commit comments

Comments
 (0)