Skip to content

fix(@angular/build) allow component HMR for templates with i18n #30248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 6, 2025
Merged
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
Expand Up @@ -285,8 +285,10 @@ export async function executeBuild(
i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined,
);

executionResult.addErrors(result.errors);
executionResult.addWarnings(result.warnings);
// Deduplicate and add errors and warnings
executionResult.addErrors([...new Set(result.errors)]);
executionResult.addWarnings([...new Set(result.warnings)]);

executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
executionResult.outputFiles.push(...result.additionalOutputFiles);
executionResult.assetFiles.push(...result.additionalAssets);
Expand Down
24 changes: 24 additions & 0 deletions packages/angular/build/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,30 @@ export async function inlineI18n(
executionResult.assetFiles = updatedAssetFiles;
}

// Inline any template updates if present
if (executionResult.templateUpdates?.size) {
// The development server only allows a single locale but issue a warning if used programmatically (experimental)
// with multiple locales and template HMR.
if (i18nOptions.inlineLocales.size > 1) {
inlineResult.warnings.push(
`Component HMR updates can only be inlined with a single locale. The first locale will be used.`,
);
}
const firstLocale = [...i18nOptions.inlineLocales][0];

for (const [id, content] of executionResult.templateUpdates) {
const templateUpdateResult = await inliner.inlineTemplateUpdate(
firstLocale,
i18nOptions.locales[firstLocale].translation,
content,
id,
);
executionResult.templateUpdates.set(id, templateUpdateResult.code);
inlineResult.errors.push(...templateUpdateResult.errors);
inlineResult.warnings.push(...templateUpdateResult.warnings);
}
}

return inlineResult;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,7 @@ export class AotCompilation extends AngularCompilation {
);
const updateText = angularCompiler.emitHmrUpdateModule(node);
// If compiler cannot generate an update for the component, prevent template updates.
// Also prevent template updates if $localize is directly present which also currently
// prevents a template update at runtime.
// TODO: Support localized template update modules and remove this check.
if (updateText === null || updateText.includes('$localize')) {
if (updateText === null) {
// Build is needed if a template cannot be updated
templateUpdates = undefined;
break;
Expand Down
49 changes: 45 additions & 4 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { loadEsmModule } from '../../utils/load-esm';
/**
* The options passed to the inliner for each file request
*/
interface InlineRequest {
interface InlineFileRequest {
/**
* The filename that should be processed. The data for the file is provided to the Worker
* during Worker initialization.
Expand All @@ -34,6 +34,31 @@ interface InlineRequest {
translation?: Record<string, unknown>;
}

/**
* The options passed to the inliner for each code request
*/
interface InlineCodeRequest {
/**
* The code that should be processed.
*/
code: string;

/**
* The filename to use in error and warning messages for the provided code.
*/
filename: string;

/**
* The locale specifier that should be used during the inlining process of the file.
*/
locale: string;

/**
* The translation messages for the locale that should be used during the inlining process of the file.
*/
translation?: Record<string, unknown>;
}

// Extract the application files and common options used for inline requests from the Worker context
// TODO: Evaluate overall performance difference of passing translations here as well
const { files, missingTranslation, shouldOptimize } = (workerData || {}) as {
Expand All @@ -47,9 +72,9 @@ const { files, missingTranslation, shouldOptimize } = (workerData || {}) as {
* This function is the main entry for the Worker's action that is called by the worker pool.
*
* @param request An InlineRequest object representing the options for inlining
* @returns An array containing the inlined file and optional map content.
* @returns An object containing the inlined file and optional map content.
*/
export default async function inlineLocale(request: InlineRequest) {
export default async function inlineFile(request: InlineFileRequest) {
const data = files.get(request.filename);

assert(data !== undefined, `Invalid inline request for file '${request.filename}'.`);
Expand All @@ -70,6 +95,22 @@ export default async function inlineLocale(request: InlineRequest) {
};
}

/**
* Inlines the provided locale and translation into JavaScript code that contains `$localize` usage.
* This function is a secondary entry primarily for use with component HMR update modules.
*
* @param request An InlineRequest object representing the options for inlining
* @returns An object containing the inlined code.
*/
export async function inlineCode(request: InlineCodeRequest) {
const result = await transformWithBabel(request.code, undefined, request);

return {
output: result.code,
messages: result.diagnostics.messages,
};
}

/**
* A Type representing the localize tools module.
*/
Expand Down Expand Up @@ -138,7 +179,7 @@ async function createI18nPlugins(locale: string, translation: Record<string, unk
async function transformWithBabel(
code: string,
map: SourceMapInput | undefined,
options: InlineRequest,
options: InlineFileRequest,
) {
let ast;
try {
Expand Down
38 changes: 38 additions & 0 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,44 @@ export class I18nInliner {
};
}

async inlineTemplateUpdate(
locale: string,
translation: Record<string, unknown> | undefined,
templateCode: string,
templateId: string,
): Promise<{ code: string; errors: string[]; warnings: string[] }> {
const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD);

if (!hasLocalize) {
return {
code: templateCode,
errors: [],
warnings: [],
};
}

const { output, messages } = await this.#workerPool.run(
{ code: templateCode, filename: templateId, locale, translation },
{ name: 'inlineCode' },
);

const errors: string[] = [];
const warnings: string[] = [];
for (const message of messages) {
if (message.type === 'error') {
errors.push(message.message);
} else {
warnings.push(message.message);
}
}

return {
code: output,
errors,
warnings,
};
}

/**
* Stops all active transformation tasks and shuts down all workers.
* @returns A void promise that resolves when closing is complete.
Expand Down