feat(core): add ng generate schematic to convert to standalone bootstrapping APIs (#48848)

Adds a new mode to the `@angular/core:standalone` schematic that allows users to switch from `bootstrapModule` to `bootstrapApplication`, as well as `provideRouter`, `provideAnimations` and `provideNoopAnimations`.

PR Close #48848
This commit is contained in:
Kristiyan Kostadinov
2023-01-20 09:53:57 +01:00
committed by Jessica Janiuk
parent 04e0ac3d7c
commit 345e737daa
4 changed files with 1271 additions and 1 deletions
@@ -16,12 +16,14 @@ import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
import {canMigrateFile, createProgramOptions} from '../../utils/typescript/compiler_host';
import {pruneNgModules} from './prune-modules';
import {toStandaloneBootstrap} from './standalone-bootstrap';
import {toStandalone} from './to-standalone';
import {ChangesByFile} from './util';
enum MigrationMode {
toStandalone = 'convert-to-standalone',
pruneModules = 'prune-ng-modules',
standaloneBootstrap = 'standalone-bootstrap',
}
interface Options {
@@ -89,6 +91,9 @@ function standaloneMigration(tree: Tree, tsconfigPath: string, basePath: string,
const result = pruneNgModules(program, host, basePath, rootNames, sourceFiles, printer);
pendingChanges = result.pendingChanges;
filesToRemove = result.filesToRemove;
} else if (options.mode === MigrationMode.standaloneBootstrap) {
pendingChanges =
toStandaloneBootstrap(program, host, basePath, rootNames, sourceFiles, printer);
} else {
/** MigrationMode.toStandalone */
pendingChanges = toStandalone(sourceFiles, program, printer);
@@ -7,7 +7,7 @@
"mode": {
"description": "Operation that should be performed by the migrator",
"type": "string",
"enum": ["convert-to-standalone", "prune-ng-modules"],
"enum": ["convert-to-standalone", "prune-ng-modules", "standalone-bootstrap"],
"default": "convert-to-standalone",
"x-prompt": {
"message": "Choose the type of migration:",
@@ -20,6 +20,10 @@
{
"value": "prune-ng-modules",
"label": "Remove unnecessary NgModule classes"
},
{
"value": "standalone-bootstrap",
"label": "Bootstrap the application using standalone APIs"
}
]
}
@@ -0,0 +1,628 @@
/*!
* @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.io/license
*/
import {NgtscProgram} from '@angular/compiler-cli';
import {Reference, TemplateTypeChecker} from '@angular/compiler-cli/private/migrations';
import {dirname, join} from 'path';
import ts from 'typescript';
import {getAngularDecorators} from '../../utils/ng_decorators';
import {closestNode} from '../../utils/typescript/nodes';
import {convertNgModuleDeclarationToStandalone} from './to-standalone';
import {ChangeTracker, createLanguageService, findClassDeclaration, findLiteralProperty, getNodeLookup, getRelativeImportPath, NamedClassDeclaration, NodeLookup, offsetsToNodes} from './util';
/** Information extracted from a `bootstrapModule` call necessary to migrate it. */
interface BootstrapCallAnalysis {
/** The call itself. */
call: ts.CallExpression;
/** Class that is being bootstrapped. */
module: ts.ClassDeclaration;
/** Metadata of the module class being bootstrapped. */
metadata: ts.ObjectLiteralExpression;
/** Component that the module is bootstrapping. */
component: NamedClassDeclaration;
}
export function toStandaloneBootstrap(
program: NgtscProgram, host: ts.CompilerHost, basePath: string, rootFileNames: string[],
sourceFiles: ts.SourceFile[], printer: ts.Printer) {
const tracker = new ChangeTracker(printer);
const typeChecker = program.getTsProgram().getTypeChecker();
const templateTypeChecker = program.compiler.getTemplateTypeChecker();
const languageService = createLanguageService(program, host, rootFileNames, basePath);
const bootstrapCalls: BootstrapCallAnalysis[] = [];
sourceFiles.forEach(function walk(node: ts.Node) {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'bootstrapModule' &&
isClassReferenceInModule(node.expression, 'PlatformRef', '@angular/core', typeChecker)) {
const call = analyzeBootstrapCall(node, typeChecker);
if (call) {
bootstrapCalls.push(call);
}
}
node.forEachChild(walk);
});
for (const call of bootstrapCalls) {
migrateBootstrapCall(call, tracker, languageService, typeChecker, templateTypeChecker, printer);
}
return tracker.recordChanges();
}
/**
* Extracts all of the information from a `bootstrapModule` call
* necessary to convert it to `bootstrapApplication`.
* @param call Call to be analyzed.
* @param typeChecker
*/
function analyzeBootstrapCall(
call: ts.CallExpression, typeChecker: ts.TypeChecker): BootstrapCallAnalysis|null {
if (call.arguments.length === 0 || !ts.isIdentifier(call.arguments[0])) {
return null;
}
const declaration = findClassDeclaration(call.arguments[0], typeChecker);
if (!declaration) {
return null;
}
const decorator = getAngularDecorators(typeChecker, ts.getDecorators(declaration) || [])
.find(decorator => decorator.name === 'NgModule');
if (!decorator || decorator.node.expression.arguments.length === 0 ||
!ts.isObjectLiteralExpression(decorator.node.expression.arguments[0])) {
return null;
}
const metadata = decorator.node.expression.arguments[0];
const bootstrapProp = findLiteralProperty(metadata, 'bootstrap');
if (!bootstrapProp || !ts.isPropertyAssignment(bootstrapProp) ||
!ts.isArrayLiteralExpression(bootstrapProp.initializer) ||
bootstrapProp.initializer.elements.length === 0 ||
!ts.isIdentifier(bootstrapProp.initializer.elements[0])) {
return null;
}
const component = findClassDeclaration(bootstrapProp.initializer.elements[0], typeChecker);
if (component && component.name && ts.isIdentifier(component.name)) {
return {module: declaration, metadata, component: component as NamedClassDeclaration, call};
}
return null;
}
/**
* Converts a `bootstrapModule` call to `bootstrapApplication`.
* @param analysis Analysis result of the call.
* @param tracker Tracker in which to register the changes.
* @param languageService
* @param typeChecker
* @param templateTypeChecker
* @param printer
*/
function migrateBootstrapCall(
analysis: BootstrapCallAnalysis, tracker: ChangeTracker, languageService: ts.LanguageService,
typeChecker: ts.TypeChecker, templateTypeChecker: TemplateTypeChecker, printer: ts.Printer) {
const sourceFile = analysis.call.getSourceFile();
const moduleSourceFile = analysis.metadata.getSourceFile();
const providers = findLiteralProperty(analysis.metadata, 'providers');
const imports = findLiteralProperty(analysis.metadata, 'imports');
const nodesToCopy = new Set<ts.Node>();
const providersInNewCall: ts.Expression[] = [];
const moduleImportsInNewCall: ts.Expression[] = [];
let nodeLookup: NodeLookup|null = null;
// The previous migrations explicitly skip over modules that bootstrap a
// component so we have to convert it as a part of this migration instead.
convertBootstrappedModuleToStandalone(analysis, tracker, templateTypeChecker);
// We can't reuse the module pruning logic, because we would have to recreate the entire program.
// Instead we comment out the module's metadata so that the user doesn't get compilation errors
// for the classes that are left in the `declarations` array. This should allow the app to
// run and the user can run module pruning themselves to get rid of the module afterwards.
tracker.insertText(
moduleSourceFile, analysis.metadata.getStart(),
'/* TODO(standalone-migration): clean up removed NgModule class manually or run the ' +
'"Remove unnecessary NgModule classes" step of the migration again. \n');
tracker.insertText(moduleSourceFile, analysis.metadata.getEnd(), ' */');
if (providers && ts.isPropertyAssignment(providers)) {
nodeLookup = nodeLookup || getNodeLookup(moduleSourceFile);
if (ts.isArrayLiteralExpression(providers.initializer)) {
providersInNewCall.push(...providers.initializer.elements);
} else {
providersInNewCall.push(ts.factory.createSpreadElement(providers.initializer));
}
addNodesToCopy(sourceFile, providers, nodeLookup, tracker, nodesToCopy, languageService);
}
if (imports && ts.isPropertyAssignment(imports)) {
nodeLookup = nodeLookup || getNodeLookup(moduleSourceFile);
migrateImportsForBootstrapCall(
sourceFile, imports, nodeLookup, moduleImportsInNewCall, providersInNewCall, tracker,
nodesToCopy, languageService, typeChecker);
}
if (nodesToCopy.size > 0) {
let text = '\n\n';
nodesToCopy.forEach(node => {
const transformedNode = remapDynamicImports(sourceFile.fileName, node);
// Use `getText` to try an preserve the original formatting. This only works if the node
// hasn't been transformed. If it has, we have to fall back to the printer.
if (transformedNode === node) {
text += transformedNode.getText() + '\n';
} else {
text += printer.printNode(ts.EmitHint.Unspecified, transformedNode, node.getSourceFile());
}
});
text += '\n';
tracker.insertText(sourceFile, getLastImportEnd(sourceFile), text);
}
replaceBootstrapCallExpression(analysis, providersInNewCall, moduleImportsInNewCall, tracker);
}
/**
* Replaces a `bootstrapModule` call with `bootstrapApplication`.
* @param analysis Analysis result of the `bootstrapModule` call.
* @param providers Providers that should be added to the new call.
* @param modules Modules that are being imported into the new call.
* @param tracker Object keeping track of the changes to the different files.
*/
function replaceBootstrapCallExpression(
analysis: BootstrapCallAnalysis, providers: ts.Expression[], modules: ts.Expression[],
tracker: ChangeTracker): void {
const sourceFile = analysis.call.getSourceFile();
const componentPath =
getRelativeImportPath(sourceFile.fileName, analysis.component.getSourceFile().fileName);
const args = [tracker.addImport(sourceFile, analysis.component.name.text, componentPath)];
const bootstrapExpression =
tracker.addImport(sourceFile, 'bootstrapApplication', '@angular/platform-browser');
if (providers.length > 0 || modules.length > 0) {
const combinedProviders: ts.Expression[] = [];
if (modules.length > 0) {
const importProvidersExpression =
tracker.addImport(sourceFile, 'importProvidersFrom', '@angular/core');
combinedProviders.push(
ts.factory.createCallExpression(importProvidersExpression, [], modules));
}
// Push the providers after `importProvidersFrom` call for better readability.
combinedProviders.push(...providers);
const initializer = remapDynamicImports(
sourceFile.fileName,
ts.factory.createArrayLiteralExpression(combinedProviders, combinedProviders.length > 1));
args.push(ts.factory.createObjectLiteralExpression(
[ts.factory.createPropertyAssignment('providers', initializer)], true));
}
tracker.replaceNode(
analysis.call, ts.factory.createCallExpression(bootstrapExpression, [], args),
// Note: it's important to pass in the source file that the nodes originated from!
// Otherwise TS won't print out literals inside of the providers that we're copying
// over from the module file.
undefined, analysis.metadata.getSourceFile());
}
/**
* Converts the declarations of a bootstrapped module to standalone. These declarations are
* skipped in the `convert-to-standalone` phase so they need to be migrated when converting
* to `bootstrapApplication`.
* @param analysis Result of the analysis of the NgModule.
* @param tracker
* @param templateTypeChecker
*/
function convertBootstrappedModuleToStandalone(
analysis: BootstrapCallAnalysis, tracker: ChangeTracker,
templateTypeChecker: TemplateTypeChecker) {
const metadata = templateTypeChecker.getNgModuleMetadata(analysis.module);
if (!metadata) {
throw new Error(`Cannot resolve NgModule metadata for class ${
analysis.module.name?.getText()}. Cannot switch to standalone bootstrap API.`);
}
const classDeclarations =
metadata.declarations.filter(decl => ts.isClassDeclaration(decl.node)) as
Reference<ts.ClassDeclaration>[];
if (!classDeclarations.some(decl => decl.node === analysis.component)) {
throw new Error(`Bootstrapped component is not in the declarations array of NgModule ${
analysis.module.name?.getText()}. Cannot switch to standalone bootstrap API.`);
}
for (const decl of classDeclarations) {
if (ts.isClassDeclaration(decl.node)) {
convertNgModuleDeclarationToStandalone(decl, classDeclarations, tracker, templateTypeChecker);
}
}
}
/**
* Processes the `imports` of an NgModule so that they can be used in the `bootstrapApplication`
* call inside of a different file.
* @param sourceFile File to which the imports will be moved.
* @param imports Node declaring the imports.
* @param nodeLookup Map used to look up nodes based on their positions in a file.
* @param importsForNewCall Array keeping track of the imports that are being added to the new call.
* @param providersInNewCall Array keeping track of the providers in the new call.
* @param tracker Tracker in which changes to files are being stored.
* @param nodesToCopy Nodes that should be copied to the new file.
* @param languageService
* @param typeChecker
*/
function migrateImportsForBootstrapCall(
sourceFile: ts.SourceFile, imports: ts.PropertyAssignment, nodeLookup: NodeLookup,
importsForNewCall: ts.Expression[], providersInNewCall: ts.Expression[], tracker: ChangeTracker,
nodesToCopy: Set<ts.Node>, languageService: ts.LanguageService,
typeChecker: ts.TypeChecker): void {
if (!ts.isArrayLiteralExpression(imports.initializer)) {
importsForNewCall.push(imports.initializer);
return;
}
for (const element of imports.initializer.elements) {
// If the reference is to a `RouterModule.forRoot` call with
// one argument, we can migrate to the new `provideRouter` API.
if (ts.isCallExpression(element) && element.arguments.length === 1 &&
ts.isPropertyAccessExpression(element.expression) &&
element.expression.name.text === 'forRoot' &&
isClassReferenceInModule(
element.expression.expression, 'RouterModule', '@angular/router', typeChecker)) {
providersInNewCall.push(ts.factory.createCallExpression(
tracker.addImport(sourceFile, 'provideRouter', '@angular/router'), [],
element.arguments));
addNodesToCopy(
sourceFile, element.arguments[0], nodeLookup, tracker, nodesToCopy, languageService);
continue;
}
if (ts.isIdentifier(element)) {
// `BrowserAnimationsModule` can be replaced with `provideAnimations`.
const animationsModule = '@angular/platform-browser/animations';
if (isClassReferenceInModule(
element, 'BrowserAnimationsModule', animationsModule, typeChecker)) {
providersInNewCall.push(ts.factory.createCallExpression(
tracker.addImport(sourceFile, 'provideAnimations', animationsModule), [], []));
continue;
}
// `NoopAnimationsModule` can be replaced with `provideNoopAnimations`.
if (isClassReferenceInModule(
element, 'NoopAnimationsModule', animationsModule, typeChecker)) {
providersInNewCall.push(ts.factory.createCallExpression(
tracker.addImport(sourceFile, 'provideNoopAnimations', animationsModule), [], []));
continue;
}
}
const target =
// If it's a call, it'll likely be a `ModuleWithProviders`
// expression so the target is going to be call's expression.
ts.isCallExpression(element) && ts.isPropertyAccessExpression(element.expression) ?
element.expression.expression :
element;
const classDeclaration = findClassDeclaration(target, typeChecker);
const decorators = classDeclaration ?
getAngularDecorators(typeChecker, ts.getDecorators(classDeclaration) || []) :
undefined;
if (!decorators || decorators.length === 0 ||
decorators.every(
({name}) => name !== 'Directive' && name !== 'Component' && name !== 'Pipe')) {
importsForNewCall.push(element);
addNodesToCopy(sourceFile, element, nodeLookup, tracker, nodesToCopy, languageService);
}
}
}
/**
* 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.
* @param targetFile File to which the nodes will be copied.
* @param rootNode Node within which to look for references.
* @param nodeLookup Map used to look up nodes based on their positions in a file.
* @param tracker Tracker in which changes to files are stored.
* @param nodesToCopy Set that keeps track of the nodes being copied.
* @param languageService
*/
function addNodesToCopy(
targetFile: ts.SourceFile, rootNode: ts.Node, nodeLookup: NodeLookup, tracker: ChangeTracker,
nodesToCopy: Set<ts.Node>, languageService: ts.LanguageService): void {
const refs = findAllSameFileReferences(rootNode, nodeLookup, languageService);
for (const ref of refs) {
const importSpecifier = closestOrSelf(ref, ts.isImportSpecifier);
const importDeclaration =
importSpecifier ? closestNode(importSpecifier, ts.isImportDeclaration) : null;
// If the reference is in an import, we need to add an import to the main file.
if (importDeclaration && importSpecifier &&
ts.isStringLiteralLike(importDeclaration.moduleSpecifier)) {
const moduleName = importDeclaration.moduleSpecifier.text.startsWith('.') ?
remapRelativeImport(targetFile.fileName, importDeclaration.moduleSpecifier) :
importDeclaration.moduleSpecifier.text;
const symbolName = importSpecifier.propertyName ? importSpecifier.propertyName.text :
importSpecifier.name.text;
const alias = importSpecifier.propertyName ? importSpecifier.name.text : null;
tracker.addImport(targetFile, symbolName, moduleName, alias);
continue;
}
const variableDeclaration = closestOrSelf(ref, ts.isVariableDeclaration);
const variableStatement =
variableDeclaration ? closestNode(variableDeclaration, ts.isVariableStatement) : null;
// If the reference is a variable, we can attempt to import it or copy it over.
if (variableDeclaration && variableStatement && ts.isIdentifier(variableDeclaration.name)) {
if (isExported(variableStatement)) {
tracker.addImport(
targetFile, variableDeclaration.name.text,
getRelativeImportPath(targetFile.fileName, ref.getSourceFile().fileName));
} else {
nodesToCopy.add(variableStatement);
}
continue;
}
// Otherwise check if the reference is inside of an exportable declaration, e.g. a function.
// This code that is safe to copy over into the new file or import it, if it's exported.
const closestExportable = closestOrSelf(ref, isExportableDeclaration);
if (closestExportable) {
if (isExported(closestExportable) && closestExportable.name) {
tracker.addImport(
targetFile, closestExportable.name.text,
getRelativeImportPath(targetFile.fileName, ref.getSourceFile().fileName));
} else {
nodesToCopy.add(closestExportable);
}
}
}
}
/**
* Finds all the nodes referenced within the root node in the same file.
* @param rootNode Node from which to start looking for references.
* @param nodeLookup Map used to look up nodes based on their positions in a file.
* @param languageService
*/
function findAllSameFileReferences(
rootNode: ts.Node, nodeLookup: NodeLookup, languageService: ts.LanguageService): Set<ts.Node> {
const results = new Set<ts.Node>();
const excludeStart = rootNode.getStart();
const excludeEnd = rootNode.getEnd();
(function walk(node) {
if (!isReferenceIdentifier(node)) {
node.forEachChild(walk);
return;
}
const refs =
referencesToNodeWithinSameFile(node, nodeLookup, excludeStart, excludeEnd, languageService);
if (refs === null) {
return;
}
for (const ref of refs) {
if (results.has(ref)) {
continue;
}
const closestTopLevel = closestNode(ref, isTopLevelStatement);
results.add(ref);
// Keep searching, starting from the closest top-level node. We skip import declarations,
// because we already know about them and they may put the search into an infinite loop.
if (closestTopLevel && !ts.isImportDeclaration(closestTopLevel) &&
isOutsideRange(
excludeStart, excludeEnd, closestTopLevel.getStart(), closestTopLevel.getEnd())) {
walk(closestTopLevel);
}
}
})(rootNode);
return results;
}
/**
* Finds all the nodes referring to a specific node within the same file.
* @param node Node whose references we're lookip for.
* @param nodeLookup Map used to look up nodes based on their positions in a file.
* @param excludeStart Start of a range that should be excluded from the results.
* @param excludeEnd End of a range that should be excluded from the results.
* @param languageService
*/
function referencesToNodeWithinSameFile(
node: ts.Identifier, nodeLookup: NodeLookup, excludeStart: number, excludeEnd: number,
languageService: ts.LanguageService): Set<ts.Node>|null {
const sourceFile = node.getSourceFile();
const fileName = sourceFile.fileName;
const highlights = languageService.getDocumentHighlights(fileName, node.getStart(), [fileName]);
if (highlights) {
const offsets: [start: number, end: number][] = [];
for (const file of highlights) {
// We are pretty much guaranteed to only have one match from the current file since it is
// the only one being passed in `getDocumentHighlight`, but we check here just in case.
if (file.fileName === fileName) {
for (const {textSpan: {start, length}, kind} of file.highlightSpans) {
const end = start + length;
if (kind !== ts.HighlightSpanKind.none &&
isOutsideRange(excludeStart, excludeEnd, start, end)) {
offsets.push([start, end]);
}
}
}
}
if (offsets.length > 0) {
const nodes = offsetsToNodes(nodeLookup, offsets, new Set());
if (nodes.size > 0) {
return nodes;
}
}
}
return null;
}
/**
* Transforms a node so that any dynamic imports with relative file paths it contains are remapped
* as if they were specified in a different file. If no transformations have occurred, the original
* node will be returned.
* @param targetFileName File name to which to remap the imports.
* @param rootNode Node being transformed.
*/
function remapDynamicImports<T extends ts.Node>(targetFileName: string, rootNode: T): T {
let hasChanged = false;
const transformer: ts.TransformerFactory<T> = context => {
return sourceFile => ts.visitNode(sourceFile, function walk(node: ts.Node): ts.Node {
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length > 0 && ts.isStringLiteralLike(node.arguments[0]) &&
node.arguments[0].text.startsWith('.')) {
hasChanged = true;
return context.factory.updateCallExpression(node, node.expression, node.typeArguments, [
context.factory.createStringLiteral(
remapRelativeImport(targetFileName, node.arguments[0])),
...node.arguments.slice(1)
]);
}
return ts.visitEachChild(node, walk, context);
});
};
const result = ts.transform(rootNode, [transformer]).transformed[0];
return hasChanged ? result : rootNode;
}
/**
* Checks whether a node is a statement at the top level of a file.
* @param node Node to be checked.
*/
function isTopLevelStatement(node: ts.Node): node is ts.Node {
return node.parent != null && ts.isSourceFile(node.parent);
}
/**
* Asserts that a node is an identifier that might be referring to a symbol. This excludes
* identifiers of named nodes like property assignments.
* @param node Node to be checked.
*/
function isReferenceIdentifier(node: ts.Node): node is ts.Identifier {
return ts.isIdentifier(node) &&
(!ts.isPropertyAssignment(node.parent) && !ts.isParameter(node.parent) ||
node.parent.name !== node);
}
/**
* Checks whether a range is completely outside of another range.
* @param excludeStart Start of the exclusion range.
* @param excludeEnd End of the exclusion range.
* @param start Start of the range that is being checked.
* @param end End of the range that is being checked.
*/
function isOutsideRange(
excludeStart: number, excludeEnd: number, start: number, end: number): boolean {
return (start < excludeStart && end < excludeStart) || start > excludeEnd;
}
/**
* Remaps the specifier of a relative import from its original location to a new one.
* @param targetFileName Name of the file that the specifier will be moved to.
* @param specifier Specifier whose path is being remapped.
*/
function remapRelativeImport(targetFileName: string, specifier: ts.StringLiteralLike): string {
return getRelativeImportPath(
targetFileName, join(dirname(specifier.getSourceFile().fileName), specifier.text));
}
/**
* Whether a node is exported.
* @param node Node to be checked.
*/
function isExported(node: ts.Node): node is ts.Node {
return ts.canHaveModifiers(node) && node.modifiers ?
node.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) :
false;
}
/**
* Gets the closest node that matches a predicate, including the node that the search started from.
* @param node Node from which to start the search.
* @param predicate Predicate that the result needs to pass.
*/
function closestOrSelf<T extends ts.Node>(node: ts.Node, predicate: (n: ts.Node) => n is T): T|
null {
return predicate(node) ? node : closestNode(node, predicate);
}
/**
* Asserts that a node is an exportable declaration, which means that it can either be exported or
* it can be safely copied into another file.
* @param node Node to be checked.
*/
function isExportableDeclaration(node: ts.Node): node is ts.EnumDeclaration|ts.ClassDeclaration|
ts.FunctionDeclaration|ts.InterfaceDeclaration|ts.TypeAliasDeclaration {
return ts.isEnumDeclaration(node) || ts.isClassDeclaration(node) ||
ts.isFunctionDeclaration(node) || ts.isInterfaceDeclaration(node) ||
ts.isTypeAliasDeclaration(node);
}
/**
* Checks whether a node is referring to a specific class declaration.
* @param node Node that is being checked.
* @param className Name of the class that the node might be referring to.
* @param moduleName Name of the node module that should contain the class.
* @param typeChecker
*/
function isClassReferenceInModule(
node: ts.Node, className: string, moduleName: string, typeChecker: ts.TypeChecker): boolean {
const symbol = typeChecker.getTypeAtLocation(node).getSymbol();
return !!symbol?.declarations?.some(decl => {
const closestClass = closestOrSelf(decl, ts.isClassDeclaration);
return closestClass && closestClass.name && ts.isIdentifier(closestClass.name) &&
closestClass.name.text === className &&
closestClass.getSourceFile().fileName.includes(moduleName);
});
}
/**
* Gets the index after the last import in a file. Can be used to insert new code into the file.
* @param sourceFile File in which to search for imports.
*/
function getLastImportEnd(sourceFile: ts.SourceFile): number {
let index = 0;
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) {
index = Math.max(index, statement.getEnd());
} else {
break;
}
}
return index;
}
@@ -1689,4 +1689,637 @@ describe('standalone migration', () => {
export class AppModule {}
`));
});
it('should switch a platformBrowser().bootstrapModule call to bootstrapApplication', async () => {
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} from '@angular/core';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({declarations: [AppComponent], bootstrap: [AppComponent]})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
bootstrapApplication(AppComponent).catch(e => console.error(e));
`));
});
it('should switch a platformBrowserDynamic().bootstrapModule call to bootstrapApplication',
async () => {
writeFile('main.ts', `
import {AppModule} from './app/app.module';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
platformBrowserDynamic().bootstrapModule(AppModule).catch(e => console.error(e));
`);
writeFile('./app/app.module.ts', `
import {NgModule, Component} from '@angular/core';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({declarations: [AppComponent], bootstrap: [AppComponent]})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {bootstrapApplication} from "@angular/platform-browser";
bootstrapApplication(AppComponent).catch(e => console.error(e));
`));
});
it('should switch a PlatformRef.bootstrapModule call to bootstrapApplication', async () => {
writeFile('main.ts', `
import {AppModule} from './app/app.module';
import {PlatformRef} from '@angular/core';
const foo: PlatformRef = null!;
foo.bootstrapModule(AppModule).catch(e => console.error(e));
`);
writeFile('./app/app.module.ts', `
import {NgModule, Component} from '@angular/core';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({declarations: [AppComponent], bootstrap: [AppComponent]})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {PlatformRef} from '@angular/core';
import {bootstrapApplication} from "@angular/platform-browser";
const foo: PlatformRef = null!;
bootstrapApplication(AppComponent).catch(e => console.error(e));
`));
});
it('should convert the root module declarations to standalone', async () => {
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.component.ts', `
import {Component} from '@angular/core';
@Component({template: '<div *ngIf="show" dir>hello</div>'})
export class AppComponent {
show = true;
}
`);
writeFile('./app/dir.ts', `
import {Directive} from '@angular/core';
@Directive({selector: '[dir]'})
export class Dir {}
`);
writeFile('./app/app.module.ts', `
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppComponent} from './app.component';
import {Dir} from './dir';
@NgModule({
imports: [CommonModule],
declarations: [AppComponent, Dir],
bootstrap: [AppComponent]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {importProvidersFrom} from "@angular/core";
import {AppComponent} from "./app/app.component";
import {CommonModule} from "@angular/common";
bootstrapApplication(AppComponent, {
providers: [importProvidersFrom(CommonModule)]
}).catch(e => console.error(e));
`));
expect(stripWhitespace(tree.readContent('./app/app.module.ts'))).toBe(stripWhitespace(`
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppComponent} from './app.component';
import {Dir} from './dir';
@NgModule(/*
TODO(standalone-migration): clean up removed NgModule class manually or run the "Remove unnecessary NgModule classes" step of the migration again.
{
imports: [CommonModule],
declarations: [AppComponent, Dir],
bootstrap: [AppComponent]
} */)
export class AppModule {}
`));
expect(stripWhitespace(tree.readContent('./app/app.component.ts'))).toBe(stripWhitespace(`
import {Component} from '@angular/core';
import {Dir} from "./dir";
import {NgIf} from "@angular/common";
@Component({
template: '<div *ngIf="show" dir>hello</div>',
standalone: true,
imports: [NgIf, Dir]
})
export class AppComponent {
show = true;
}
`));
expect(stripWhitespace(tree.readContent('./app/dir.ts'))).toBe(stripWhitespace(`
import {Directive} from '@angular/core';
@Directive({selector: '[dir]', standalone: true})
export class Dir {}
`));
});
it('should copy providers and the symbols they depend on to the main file', async () => {
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, InjectionToken} from '@angular/core';
import {externalToken} from './externals/token';
import {externalToken as aliasedExternalToken} from './externals/other-token';
import {ExternalInterface} from '@external/interfaces';
import {InternalInterface} from './interfaces/internal-interface';
const internalToken = new InjectionToken<string>('internalToken');
export const exportedToken = new InjectionToken<InternalInterface>('exportedToken');
export class ExportedClass {}
export function exportedFactory(value: InternalInterface) {
return value.foo;
}
const unexportedExtraProviders = [
{provide: aliasedExternalToken, useFactory: (value: ExternalInterface) => value.foo}
];
export const exportedExtraProviders = [
{provide: exportedToken, useClass: ExportedClass}
];
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: [
{provide: internalToken, useValue: 'hello'},
{provide: externalToken, useValue: 123, multi: true},
{provide: externalToken, useFactory: exportedFactory, multi: true},
...unexportedExtraProviders,
...exportedExtraProviders
]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
// Note that this leaves a couple of unused imports from `app.module`.
// The schematic optimizes for safety, rather than avoiding unused imports.
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import { AppModule, exportedToken, exportedExtraProviders, ExportedClass, exportedFactory, AppComponent } from './app/app.module';
import { platformBrowser, bootstrapApplication } from '@angular/platform-browser';
import { ExternalInterface } from "@external/interfaces";
import { externalToken as aliasedExternalToken } from "./app/externals/other-token";
import { externalToken } from "./app/externals/token";
import { InternalInterface } from "./app/interfaces/internal-interface";
import { InjectionToken } from "@angular/core";
const internalToken = new InjectionToken<string>('internalToken');
const unexportedExtraProviders = [
{provide: aliasedExternalToken, useFactory: (value: ExternalInterface) => value.foo}
];
bootstrapApplication(AppComponent, {
providers: [
{ provide: internalToken, useValue: 'hello' },
{ provide: externalToken, useValue: 123, multi: true },
{ provide: externalToken, useFactory: exportedFactory, multi: true },
...unexportedExtraProviders,
...exportedExtraProviders
]
}).catch(e => console.error(e));
`));
});
it('should not copy over non-declaration references to the main file', async () => {
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, InjectionToken} from '@angular/core';
export const token = new InjectionToken<string>('token');
console.log(token);
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: [{provide: token, useValue: 'hello'}]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, token, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {InjectionToken} from "@angular/core";
bootstrapApplication(AppComponent, {
providers: [{ provide: token, useValue: 'hello' }]
}).catch(e => console.error(e));
`));
});
it('should update dynamic imports from the `providers` that are copied to the main file',
async () => {
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} from '@angular/core';
import {ROUTES} from '@angular/router';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: [{
provide: ROUTES,
useValue: [
{path: 'internal-comp', loadComponent: () => import('../routes/internal-comp').then(c => c.InternalComp)},
{path: 'external-comp', loadComponent: () => import('@external/external-comp').then(c => c.ExternalComp)}
]
}]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {ROUTES} from "@angular/router";
bootstrapApplication(AppComponent, {
providers: [{
provide: ROUTES,
useValue: [
{path: 'internal-comp', loadComponent: () => import("./routes/internal-comp").then(c => c.InternalComp)},
{path: 'external-comp', loadComponent: () => import('@external/external-comp').then(c => c.ExternalComp)}
]
}]
}).catch(e => console.error(e));
`));
});
it('should copy modules from the `imports` array to the `providers` and wrap them in `imporProvidersFrom`',
async () => {
writeFile('main.ts', `
import {AppModule} from './app/app.module';
import {platformBrowser} from '@angular/platform-browser';
platformBrowser().bootstrapModule(AppModule).catch(e => console.error(e));
`);
writeFile('./modules/internal.module.ts', `
import {NgModule} from '@angular/core';
@NgModule()
export class InternalModule {}
`);
writeFile('./app/app.module.ts', `
import {NgModule, Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {InternalModule} from '../modules/internal.module';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule()
export class SameFileModule {}
@NgModule({
imports: [CommonModule, InternalModule, SameFileModule],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, SameFileModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {NgModule, importProvidersFrom} from "@angular/core";
import {InternalModule} from "./modules/internal.module";
import {CommonModule} from "@angular/common";
bootstrapApplication(AppComponent, {
providers: [importProvidersFrom(CommonModule, InternalModule, SameFileModule)]
}).catch(e => console.error(e));
`));
});
it('should switch RouterModule.forRoot calls with one argument to provideRouter', async () => {
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} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [
RouterModule.forRoot([
{path: 'internal-comp', loadComponent: () => import("./routes/internal-comp").then(c => c.InternalComp) },
{path: 'external-comp', loadComponent: () => import('@external/external-comp').then(c => c.ExternalComp) }
])
]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {provideRouter} from "@angular/router";
bootstrapApplication(AppComponent, {
providers: [provideRouter([
{path: 'internal-comp', loadComponent: () => import("./app/routes/internal-comp").then(c => c.InternalComp)},
{path: 'external-comp', loadComponent: () => import('@external/external-comp').then(c => c.ExternalComp)}
])]
}).catch(e => console.error(e));
`));
});
it('should preserve RouterModule.forRoot calls with multiple arguments', async () => {
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} from '@angular/core';
import {RouterModule} from '@angular/router';
import {APP_ROUTES} from './routes';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [RouterModule.forRoot(APP_ROUTES, {})]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {importProvidersFrom} from "@angular/core";
import {APP_ROUTES} from "./app/routes";
import {RouterModule} from "@angular/router";
bootstrapApplication(AppComponent, {
providers: [importProvidersFrom(RouterModule.forRoot(APP_ROUTES, {}))]
}).catch(e => console.error(e));
`));
});
it('should convert BrowserAnimationsModule references to provideAnimations', async () => {
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} from '@angular/core';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [BrowserAnimationsModule]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {provideAnimations} from "@angular/platform-browser/animations";
bootstrapApplication(AppComponent, {
providers: [provideAnimations()]
}).catch(e => console.error(e));
`));
});
it('should preserve BrowserAnimationsModule.withConfig calls', async () => {
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} from '@angular/core';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [BrowserAnimationsModule.withConfig({disableAnimations: true})]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {importProvidersFrom} from "@angular/core";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
bootstrapApplication(AppComponent, {
providers: [importProvidersFrom(BrowserAnimationsModule.withConfig({disableAnimations: true}))]
}).catch(e => console.error(e));
`));
});
it('should convert NoopAnimationsModule references to provideNoopAnimations', async () => {
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} from '@angular/core';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
@Component({template: 'hello'})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [NoopAnimationsModule]
})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {provideNoopAnimations} from "@angular/platform-browser/animations";
bootstrapApplication(AppComponent, {
providers: [provideNoopAnimations()]
}).catch(e => console.error(e));
`));
});
it('should omit standalone directives from the imports array from the importProvidersFrom call',
async () => {
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, Directive} from '@angular/core';
import {CommonModule} from '@angular/common';
@Directive({selector: '[dir]', standalone: true})
export class Dir {}
@Component({template: '<span dir></span>'})
export class AppComponent {}
@NgModule({imports: [Dir, CommonModule], declarations: [AppComponent], bootstrap: [AppComponent]})
export class AppModule {}
`);
await runMigration('standalone-bootstrap');
expect(stripWhitespace(tree.readContent('main.ts'))).toBe(stripWhitespace(`
import {AppModule, AppComponent} from './app/app.module';
import {platformBrowser, bootstrapApplication} from '@angular/platform-browser';
import {importProvidersFrom} from "@angular/core";
import {CommonModule} from "@angular/common";
bootstrapApplication(AppComponent, {
providers: [importProvidersFrom(CommonModule)]
}).catch(e => console.error(e));
`));
expect(stripWhitespace(tree.readContent('./app/app.module.ts'))).toContain(stripWhitespace(`
@Component({template: '<span dir></span>', standalone: true, imports: [Dir]})
export class AppComponent {}
`));
});
});