Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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',
`
<p id="hello" i18n="An introduction header for this sample">Hello {{ title }}! </p>
`,
);

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:<sha256>*/` 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 = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="4286451273117902052" datatype="html">
<target>Bonjour <x id="INTERPOLATION" equiv-text="{{ title }}"/>! </target>
<context-group purpose="location">
<context context-type="targetfile">src/app/app.component.html</context>
<context context-type="linenumber">2,3</context>
</context-group>
<note priority="1" from="description">An introduction header for this sample</note>
</trans-unit>
</body>
</file>
</xliff>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -653,7 +636,6 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu
'ngHmrMode': options.templateUpdates ? 'true' : 'false',
},
loader: loaderExtensions,
footer,
plugins,
};
}
Expand Down
113 changes: 96 additions & 17 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

// 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');
Comment on lines +225 to +227
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for generating the 8-character uppercase alphanumeric hash (hashValue.toString(36).slice(0, 8).toUpperCase().padStart(8, '0')) is quite dense. Extracting this into a small, private helper method (e.g., _generateShortHash) would improve readability and encapsulate this specific hashing algorithm.

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,
Expand Down Expand Up @@ -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, string>): string {
if (renameMap.size === 0) {
return content;
}

for (const [oldName, newName] of renameMap) {
content = content.replaceAll(oldName, newName);
}

return content;
}
Loading