diff --git a/packages/core/schematics/ng-generate/standalone-migration/index.ts b/packages/core/schematics/ng-generate/standalone-migration/index.ts index bc7913d2a5c..ff4916c5ba5 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/index.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/index.ts @@ -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); diff --git a/packages/core/schematics/ng-generate/standalone-migration/schema.json b/packages/core/schematics/ng-generate/standalone-migration/schema.json index 94e3bfc98e7..be309141258 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/schema.json +++ b/packages/core/schematics/ng-generate/standalone-migration/schema.json @@ -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" } ] } diff --git a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts new file mode 100644 index 00000000000..b1ff61ac678 --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts @@ -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(); + 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[]; + + 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, 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, 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 { + const results = new Set(); + 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|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(targetFileName: string, rootNode: T): T { + let hasChanged = false; + const transformer: ts.TransformerFactory = 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(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; +} diff --git a/packages/core/schematics/test/standalone_migration_spec.ts b/packages/core/schematics/test/standalone_migration_spec.ts index 24cdba4b141..c5adb341067 100644 --- a/packages/core/schematics/test/standalone_migration_spec.ts +++ b/packages/core/schematics/test/standalone_migration_spec.ts @@ -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: '
hello
'}) + 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: '
hello
', + 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('internalToken'); + export const exportedToken = new InjectionToken('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('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('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: ''}) + 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: '', standalone: true, imports: [Dir]}) + export class AppComponent {} + `)); + }); });