fix(migrations): preserve registerLocaleData calls in standalone bootstrap migration

Currently the standalone migration only copies symbols referenced from the NgModule metadata into the main file. Top-level `registerLocaleData` calls, and the default imports they depend on, are dropped silently when the module file is pruned, which breaks locale resolution at runtime with a "Missing locale data" error.

Copies top-level `registerLocaleData` calls from the file of the bootstrapped module into the main file, and adds support for carrying over default imports which were previously skipped silently by the reference resolution.

Fixes #50886

(cherry picked from commit bdc09e8183)
This commit is contained in:
aparziale
2026-08-21 00:20:18 +02:00
committed by leonsenft
parent 93d7f718d2
commit 85c8829ac1
2 changed files with 135 additions and 0 deletions
@@ -13,7 +13,9 @@ import ts from 'typescript';
import {ChangeTracker, ImportRemapper} from '../../utils/change_tracker';
import {getAngularDecorators} from '../../utils/ng_decorators';
import {getImportSpecifier} from '../../utils/typescript/imports';
import {closestNode} from '../../utils/typescript/nodes';
import {isReferenceToImport} from '../../utils/typescript/symbol';
import {
DeclarationImportsRemapper,
@@ -255,6 +257,22 @@ function migrateBootstrapCall(
);
}
// Top-level `registerLocaleData` calls aren't referenced from the module's metadata so they
// have to be carried over explicitly, otherwise the locale data won't be registered anymore
// once the module file is pruned.
if (moduleSourceFile !== sourceFile) {
const localeDataCalls = findRegisterLocaleDataCalls(moduleSourceFile, typeChecker);
if (localeDataCalls.length > 0) {
nodeLookup = nodeLookup || getNodeLookup(moduleSourceFile);
for (const statement of localeDataCalls) {
addNodesToCopy(sourceFile, statement, nodeLookup, tracker, nodesToCopy, referenceResolver);
nodesToCopy.add(statement);
}
}
}
if (additionalProviders) {
additionalProviders.forEach((moduleSpecifier, name) => {
providersInNewCall.push(
@@ -633,6 +651,30 @@ function getRouterModuleForRootFeatures(
return featureExpressions;
}
/**
* Finds all the top-level `registerLocaleData` calls within a file.
* @param sourceFile File in which to search for the calls.
* @param typeChecker
*/
function findRegisterLocaleDataCalls(
sourceFile: ts.SourceFile,
typeChecker: ts.TypeChecker,
): ts.ExpressionStatement[] {
const importSpecifier = getImportSpecifier(sourceFile, '@angular/common', 'registerLocaleData');
if (importSpecifier === null) {
return [];
}
return sourceFile.statements.filter(
(statement): statement is ts.ExpressionStatement =>
ts.isExpressionStatement(statement) &&
ts.isCallExpression(statement.expression) &&
ts.isIdentifier(statement.expression.expression) &&
isReferenceToImport(typeChecker, statement.expression.expression, importSpecifier),
);
}
/**
* Finds all the nodes that are referenced inside a root node and would need to be copied into a
* new file in order for the node to compile, and tracks them.
@@ -676,6 +718,37 @@ function addNodesToCopy(
continue;
}
// The reference can also be a default import (e.g. locale data files like
// `import localeFr from '@angular/common/locales/fr'`) which the import manager
// doesn't support. Copy the import declaration over or recreate it as a named
// import of the `default` symbol if it can't be copied verbatim.
// Note: when the import clause consists only of a default import, the clause and its name
// have the same offsets so the node lookup can resolve the reference to either of the two.
const importClause = closestOrSelf(ref, ts.isImportClause);
const defaultImportName =
importClause !== null && (ref === importClause || ref === importClause.name)
? (importClause.name ?? null)
: null;
if (importClause && defaultImportName) {
const declaration = closestNode(importClause, ts.isImportDeclaration);
if (declaration && ts.isStringLiteralLike(declaration.moduleSpecifier)) {
if (
importClause.namedBindings === undefined &&
!declaration.moduleSpecifier.text.startsWith('.')
) {
nodesToCopy.add(declaration);
} else {
const moduleName = declaration.moduleSpecifier.text.startsWith('.')
? remapRelativeImport(targetFile.fileName, declaration.moduleSpecifier)
: declaration.moduleSpecifier.text;
tracker.addImport(targetFile, 'default', moduleName, defaultImportName.text);
}
continue;
}
}
const variableDeclaration = closestOrSelf(ref, ts.isVariableDeclaration);
const variableStatement = variableDeclaration
? closestNode(variableDeclaration, ts.isVariableStatement)
@@ -128,6 +128,8 @@ describe('standalone migration', () => {
[typeof NgIf, typeof NgForOf], [typeof NgIf, typeof NgForOf]>;
}
export declare function registerLocaleData(data: any, localeId?: any, extraData?: any): void;
export {NgForOf as NgFor};
`,
);
@@ -4285,6 +4287,66 @@ describe('standalone migration', () => {
);
});
it('should copy top-level registerLocaleData calls to the main file', async () => {
writeFile(
'/node_modules/@angular/common/locales/fr.d.ts',
`
declare const localeFr: unknown[];
export default localeFr;
`,
);
writeFile(
'main.ts',
`
import {AppModule} from './app/app.module';
import {platformBrowser} from '@angular/platform-browser';
platformBrowser().bootstrapModule(AppModule).catch(e => console.error(e));
`,
);
writeFile(
'./app/app.module.ts',
`
import {NgModule, Component, LOCALE_ID} from '@angular/core';
import {registerLocaleData} from '@angular/common';
import localeFr from '@angular/common/locales/fr';
registerLocaleData(localeFr);
@Component({template: 'hello', standalone: false})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: [{provide: LOCALE_ID, useValue: 'fr'}]
})
export class AppModule {}
`,
);
await runMigration('standalone-bootstrap');
const content = stripWhitespace(tree.readContent('main.ts'));
expect(content).toContain(
stripWhitespace(`import localeFr from '@angular/common/locales/fr';`),
);
expect(content).toContain(
stripWhitespace(`import {registerLocaleData} from '@angular/common';`),
);
expect(content).toContain(stripWhitespace(`registerLocaleData(localeFr);`));
expect(content).toContain(
stripWhitespace(`
bootstrapApplication(AppComponent, {
providers: [{provide: LOCALE_ID, useValue: 'fr'}]
}).catch(e => console.error(e));
`),
);
});
it('should not copy over non-declaration references to the main file', async () => {
writeFile(
'main.ts',