diff --git a/packages/core/schematics/ng-generate/standalone-migration/README.md b/packages/core/schematics/ng-generate/standalone-migration/README.md index 9d94905599b..a1cbac3317b 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/README.md +++ b/packages/core/schematics/ng-generate/standalone-migration/README.md @@ -225,7 +225,9 @@ export class ExportedConfigClass {} RouterModule.forRoot([{ path: 'shop', loadComponent: () => import('./shop/shop.component').then(m => m.ShopComponent) - }]) + }], { + initialNavigation: 'enabledBlocking' + }) ], declarations: [AppComponent], bootstrap: [AppComponent], @@ -302,7 +304,7 @@ export class AppComponent {} // ./main.ts import {platformBrowser, bootstrapApplication} from '@angular/platform-browser'; import {InjectionToken, importProvidersFrom} from '@angular/core'; -import {provideRouter} from '@angular/router'; +import {withEnabledBlockingInitialNavigation, provideRouter} from '@angular/router'; import {provideAnimations} from '@angular/platform-browser/animations'; import {AppModule, ExportedConfigClass} from './app/app.module'; import {AppComponent} from './app/app.component'; @@ -326,7 +328,7 @@ bootstrapApplication(AppComponent, { provideRouter([{ path: 'shop', loadComponent: () => import('./app/shop/shop.component').then(m => m.ShopComponent) - }]) + }], withEnabledBlockingInitialNavigation()) ] }).catch(e => console.error(e)); ``` diff --git a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts index b1ff61ac678..44b4b9b1a9a 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/standalone-bootstrap.ts @@ -16,7 +16,7 @@ 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'; +import {ChangeTracker, createLanguageService, findClassDeclaration, findLiteralProperty, getNodeLookup, getRelativeImportPath, NamedClassDeclaration, NodeLookup, offsetsToNodes, UniqueItemTracker} from './util'; /** Information extracted from a `bootstrapModule` call necessary to migrate it. */ interface BootstrapCallAnalysis { @@ -281,19 +281,27 @@ function migrateImportsForBootstrapCall( } 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' && + // If the reference is to a `RouterModule.forRoot` call, we can try to migrate it. + if (ts.isCallExpression(element) && ts.isPropertyAccessExpression(element.expression) && + element.arguments.length > 0 && 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; + const options = element.arguments[1] as ts.Expression | undefined; + const features = options ? getRouterModuleForRootFeatures(sourceFile, options, tracker) : []; + + // If the features come back as null, it means that the router + // has a configuration that can't be migrated automatically. + if (features !== null) { + providersInNewCall.push(ts.factory.createCallExpression( + tracker.addImport(sourceFile, 'provideRouter', '@angular/router'), [], + [element.arguments[0], ...features])); + addNodesToCopy( + sourceFile, element.arguments[0], nodeLookup, tracker, nodesToCopy, languageService); + if (options) { + addNodesToCopy(sourceFile, options, nodeLookup, tracker, nodesToCopy, languageService); + } + continue; + } } if (ts.isIdentifier(element)) { @@ -335,6 +343,111 @@ function migrateImportsForBootstrapCall( } } +/** + * Generates the call expressions that can be used to replace the options + * object that is passed into a `RouterModule.forRoot` call. + * @param sourceFile File that the `forRoot` call is coming from. + * @param options Node that is passed as the second argument to the `forRoot` call. + * @param tracker Tracker in which to track imports that need to be inserted. + * @returns Null if the options can't be migrated, otherwise an array of call expressions. + */ +function getRouterModuleForRootFeatures( + sourceFile: ts.SourceFile, options: ts.Expression, tracker: ChangeTracker): ts.CallExpression[]| + null { + // Options that aren't a static object literal can't be migrated. + if (!ts.isObjectLiteralExpression(options)) { + return null; + } + + const featureExpressions: ts.CallExpression[] = []; + const configOptions: ts.PropertyAssignment[] = []; + const inMemoryScrollingOptions: ts.PropertyAssignment[] = []; + const features = new UniqueItemTracker(); + + for (const prop of options.properties) { + // We can't migrate options that we can't easily analyze. + if (!ts.isPropertyAssignment(prop) || + (!ts.isIdentifier(prop.name) && !ts.isStringLiteralLike(prop.name))) { + return null; + } + + switch (prop.name.text) { + // `preloadingStrategy` maps to the `withPreloading` function. + case 'preloadingStrategy': + features.track('withPreloading', prop.initializer); + break; + + // `enableTracing: true` maps to the `withDebugTracing` feature. + case 'enableTracing': + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + features.track('withDebugTracing', null); + } + break; + + // `initialNavigation: 'enabled'` and `initialNavigation: 'enabledBlocking'` map to the + // `withEnabledBlockingInitialNavigation` feature, while `initialNavigation: 'disabled'` maps + // to the `withDisabledInitialNavigation` feature. + case 'initialNavigation': + if (!ts.isStringLiteralLike(prop.initializer)) { + return null; + } + if (prop.initializer.text === 'enabledBlocking' || prop.initializer.text === 'enabled') { + features.track('withEnabledBlockingInitialNavigation', null); + } else if (prop.initializer.text === 'disabled') { + features.track('withDisabledInitialNavigation', null); + } + break; + + // `useHash: true` maps to the `withHashLocation` feature. + case 'useHash': + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + features.track('withHashLocation', null); + } + break; + + // `errorHandler` maps to the `withNavigationErrorHandler` feature. + case 'errorHandler': + features.track('withNavigationErrorHandler', prop.initializer); + break; + + // `anchorScrolling` and `scrollPositionRestoration` arguments have to be combined into an + // object literal that is passed into the `withInMemoryScrolling` feature. + case 'anchorScrolling': + case 'scrollPositionRestoration': + inMemoryScrollingOptions.push(prop); + break; + + // All remaining properties can be passed through the `withRouterConfig` feature. + default: + configOptions.push(prop); + break; + } + } + + if (inMemoryScrollingOptions.length > 0) { + features.track( + 'withInMemoryScrolling', + ts.factory.createObjectLiteralExpression(inMemoryScrollingOptions)); + } + + if (configOptions.length > 0) { + features.track('withRouterConfig', ts.factory.createObjectLiteralExpression(configOptions)); + } + + for (const [feature, featureArgs] of features.getEntries()) { + const callArgs: ts.Expression[] = []; + featureArgs.forEach(arg => { + if (arg !== null) { + callArgs.push(arg); + } + }); + featureExpressions.push(ts.factory.createCallExpression( + tracker.addImport(sourceFile, feature, '@angular/router'), [], callArgs)); + } + + return featureExpressions; +} + /** * 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. diff --git a/packages/core/schematics/test/standalone_migration_spec.ts b/packages/core/schematics/test/standalone_migration_spec.ts index 094f52cb509..d4408b9dba0 100644 --- a/packages/core/schematics/test/standalone_migration_spec.ts +++ b/packages/core/schematics/test/standalone_migration_spec.ts @@ -112,7 +112,7 @@ describe('standalone migration', () => { import {ModuleWithProviders} from '@angular/core'; export declare class RouterModule { - static forRoot(routes: any[]): ModuleWithProviders; + static forRoot(routes: any[], config?: any): ModuleWithProviders; } `); @@ -2377,7 +2377,7 @@ describe('standalone migration', () => { `)); }); - it('should preserve RouterModule.forRoot calls with multiple arguments', async () => { + it('should migrate a RouterModule.forRoot call with an empty config', async () => { writeFile('main.ts', ` import {AppModule} from './app/app.module'; import {platformBrowser} from '@angular/platform-browser'; @@ -2406,16 +2406,420 @@ describe('standalone migration', () => { 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'; + import {provideRouter} from '@angular/router'; bootstrapApplication(AppComponent, { - providers: [importProvidersFrom(RouterModule.forRoot(APP_ROUTES, {}))] + providers: [provideRouter(APP_ROUTES)] }).catch(e => console.error(e)); `)); }); + it('should migrate a router config with a preloadingStrategy to withPreloading', 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 {of} from 'rxjs'; + + @Component({template: 'hello'}) + export class AppComponent {} + + @NgModule({ + declarations: [AppComponent], + bootstrap: [AppComponent], + imports: [RouterModule.forRoot([], { + preloadingStrategy: () => of(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 {of} from 'rxjs'; + import {withPreloading, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withPreloading(() => of(true)))] + }).catch(e => console.error(e)); + `)); + }); + + it('should migrate a router config with enableTracing to withDebugTracing', 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([], { + enableTracing: 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 {withDebugTracing, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withDebugTracing())] + }).catch(e => console.error(e)); + `)); + }); + + it('should migrate a router config with `initialNavigation: "enabledBlocking"` to withEnabledBlockingInitialNavigation', + 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([], { + initialNavigation: 'enabledBlocking' + })] + }) + 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 {withEnabledBlockingInitialNavigation, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withEnabledBlockingInitialNavigation())] + }).catch(e => console.error(e)); + `)); + }); + + it('should migrate a router config with `initialNavigation: "enabled"` to withEnabledBlockingInitialNavigation', + 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([], { + initialNavigation: 'enabled' + })] + }) + 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 {withEnabledBlockingInitialNavigation, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withEnabledBlockingInitialNavigation())] + }).catch(e => console.error(e)); + `)); + }); + + it('should migrate a router config with `initialNavigation: "disabled"` to withDisabledInitialNavigation', + 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([], { + initialNavigation: 'disabled' + })] + }) + 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 {withDisabledInitialNavigation, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withDisabledInitialNavigation())] + }).catch(e => console.error(e)); + `)); + }); + + it('should migrate a router config with useHash to withHashLocation', 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([], { + useHash: 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 {withHashLocation, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withHashLocation())] + }).catch(e => console.error(e)); + `)); + }); + + it('should migrate a router config with errorHandler to withNavigationErrorHandler', 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([], { + errorHandler: () => {} + })] + }) + 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 {withNavigationErrorHandler, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withNavigationErrorHandler(() => {}))] + }).catch(e => console.error(e)); + `)); + }); + + it('should combine the anchorScrolling and scrollPositionRestoration of a router config into withInMemoryScrolling', + 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([], { + anchorScrolling: 'enabled', + scrollPositionRestoration: 'top' + })] + }) + 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 {withInMemoryScrolling, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withInMemoryScrolling({ + anchorScrolling: 'enabled', + scrollPositionRestoration: 'top' + }))] + }).catch(e => console.error(e)); + `)); + }); + + it('should copy properties that do not map to a feature into withRouterConfig', 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([], { + canceledNavigationResolution: 'replace', + useHash: true, + paramsInheritanceStrategy: 'always' + })] + }) + 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 {withHashLocation, withRouterConfig, provideRouter} from '@angular/router'; + + bootstrapApplication(AppComponent, { + providers: [provideRouter([], withHashLocation(), withRouterConfig({ + canceledNavigationResolution: 'replace', + paramsInheritanceStrategy: 'always' + }))] + }).catch(e => console.error(e)); + `)); + }); + + it('should preserve a RouterModule.forRoot where the config cannot be statically analyzed', + 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 {} + + const extraOptions = {useHash: true}; + + @NgModule({ + declarations: [AppComponent], + bootstrap: [AppComponent], + imports: [RouterModule.forRoot([], { + enableTracing: true, + ...extraOptions + })] + }) + 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 {RouterModule} from '@angular/router'; + + const extraOptions = {useHash: true}; + + bootstrapApplication(AppComponent, { + providers: [importProvidersFrom(RouterModule.forRoot([], { + enableTracing: true, + ...extraOptions + }))] + }).catch(e => console.error(e)); + `)); + }); + it('should convert BrowserAnimationsModule references to provideAnimations', async () => { writeFile('main.ts', ` import {AppModule} from './app/app.module';