diff --git a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts index 00596aee972..abb74a127ef 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts @@ -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) diff --git a/packages/core/schematics/test/standalone_migration_spec.ts b/packages/core/schematics/test/standalone_migration_spec.ts index fe4450cc067..bfb83074730 100644 --- a/packages/core/schematics/test/standalone_migration_spec.ts +++ b/packages/core/schematics/test/standalone_migration_spec.ts @@ -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',