From e200d2a0c55af1cd975f64efea4069dda3d44459 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 18 Mar 2025 13:47:03 +0000 Subject: [PATCH 1/2] feat(@angular/ssr): expose `provideServerRendering` and remove `provideServerRouting` This commit introduces `provideServerRendering` as the primary function for configuring server-side rendering, replacing `provideServerRouting`. `provideServerRendering` now includes the functionality of `provideServerRouting` through the use of the `withRoutes` feature. This change consolidates server-side rendering configuration into a single, more flexible function, aligning with the evolution of Angular SSR. **Before:** ```ts import { provideServerRouting } from '@angular/ssr'; import { serverRoutes } from './app.routes'; provideServerRouting(serverRoutes); ``` **After:** ```ts import { provideServerRendering, withRoutes } from '@angular/ssr'; import { serverRoutes } from './app.routes'; provideServerRendering(withRoutes(serverRoutes)); ``` --- goldens/public-api/angular/ssr/index.api.md | 7 +- packages/angular/ssr/public_api.ts | 3 +- .../angular/ssr/src/routes/route-config.ts | 199 +++++++++++++----- packages/angular/ssr/test/BUILD.bazel | 1 - .../ssr/test/npm_package/package_spec.ts | 6 - packages/angular/ssr/test/testing-utils.ts | 6 +- .../schematics/angular/app-shell/index.ts | 4 +- .../angular/app-shell/index_spec.ts | 4 +- .../app/app.module.server.ts.template | 7 +- .../app/app.config.server.ts.template | 6 +- .../app/app.config.server.ts.template | 2 +- 11 files changed, 171 insertions(+), 74 deletions(-) diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index f6a0d089b4b4..81764fcc1f62 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -27,7 +27,7 @@ export enum PrerenderFallback { } // @public -export function provideServerRouting(routes: ServerRoute[], ...features: ServerRoutesFeature[]): EnvironmentProviders; +export function provideServerRendering(...features: ServerRenderingFeature[]): EnvironmentProviders; // @public export enum RenderMode { @@ -72,7 +72,10 @@ export interface ServerRouteServer extends ServerRouteCommon { } // @public -export function withAppShell(component: Type | (() => Promise | DefaultExport>>)): ServerRoutesFeature; +export function withAppShell(component: Type | (() => Promise | DefaultExport>>)): ServerRenderingFeature; + +// @public +export function withRoutes(routes: ServerRoute[]): ServerRenderingFeature; // (No @packageDocumentation comment for this package) diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index fbeadeac929f..e685f4ceabe3 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -14,8 +14,9 @@ export { createRequestHandler, type RequestHandlerFunction } from './src/handler export { PrerenderFallback, type ServerRoute, - provideServerRouting, + provideServerRendering, withAppShell, + withRoutes, RenderMode, type ServerRouteClient, type ServerRoutePrerender, diff --git a/packages/angular/ssr/src/routes/route-config.ts b/packages/angular/ssr/src/routes/route-config.ts index d0a2306134c6..c07c9d76081e 100644 --- a/packages/angular/ssr/src/routes/route-config.ts +++ b/packages/angular/ssr/src/routes/route-config.ts @@ -11,8 +11,11 @@ import { InjectionToken, Provider, Type, + inject, makeEnvironmentProviders, + provideEnvironmentInitializer, } from '@angular/core'; +import { provideServerRendering as provideServerRenderingPlatformServer } from '@angular/platform-server'; import { type DefaultExport, ROUTES, type Route } from '@angular/router'; /** @@ -22,25 +25,26 @@ import { type DefaultExport, ROUTES, type Route } from '@angular/router'; const APP_SHELL_ROUTE = 'ng-app-shell'; /** - * Identifies a particular kind of `ServerRoutesFeatureKind`. - * @see {@link ServerRoutesFeature} + * Identifies a particular kind of `ServerRenderingFeatureKind`. + * @see {@link ServerRenderingFeature} */ -enum ServerRoutesFeatureKind { +enum ServerRenderingFeatureKind { AppShell, + ServerRoutes, } /** * Helper type to represent a server routes feature. - * @see {@link ServerRoutesFeatureKind} + * @see {@link ServerRenderingFeatureKind} */ -interface ServerRoutesFeature { +interface ServerRenderingFeature { ɵkind: FeatureKind; - ɵproviders: Provider[]; + ɵproviders: (Provider | EnvironmentProviders)[]; } /** * Different rendering modes for server routes. - * @see {@link provideServerRouting} + * @see {@link withRoutes} * @see {@link ServerRoute} */ export enum RenderMode { @@ -171,7 +175,7 @@ export interface ServerRouteServer extends ServerRouteCommon { /** * Server route configuration. - * @see {@link provideServerRouting} + * @see {@link withRoutes} */ export type ServerRoute = | ServerRouteClient @@ -200,62 +204,103 @@ export interface ServerRoutesConfig { export const SERVER_ROUTES_CONFIG = new InjectionToken('SERVER_ROUTES_CONFIG'); /** - * Sets up the necessary providers for configuring server routes. - * This function accepts an array of server routes and optional configuration - * options, returning an `EnvironmentProviders` object that encapsulates - * the server routes and configuration settings. + * Configures server-side routing for the application. * - * @param routes - An array of server routes to be provided. - * @param features - (Optional) server routes features. - * @returns An `EnvironmentProviders` instance with the server routes configuration. + * This function registers an array of `ServerRoute` definitions, enabling server-side rendering + * for specific URL paths. These routes are used to pre-render content on the server, improving + * initial load performance and SEO. * + * @param routes - An array of `ServerRoute` objects, each defining a server-rendered route. + * @returns A `ServerRenderingFeature` object configuring server-side routes. + * + * @example + * ```ts + * import { provideServerRendering, withRoutes, ServerRoute, RenderMode } from '@angular/ssr'; + * + * const serverRoutes: ServerRoute[] = [ + * { + * route: '', // This renders the "/" route on the client (CSR) + * renderMode: RenderMode.Client, + * }, + * { + * route: 'about', // This page is static, so we prerender it (SSG) + * renderMode: RenderMode.Prerender, + * }, + * { + * route: 'profile', // This page requires user-specific data, so we use SSR + * renderMode: RenderMode.Server, + * }, + * { + * route: '**', // All other routes will be rendered on the server (SSR) + * renderMode: RenderMode.Server, + * }, + * ]; + * + * provideServerRendering(withRoutes(serverRoutes)); + * ``` + * + * @see {@link provideServerRendering} * @see {@link ServerRoute} - * @see {@link withAppShell} */ -export function provideServerRouting( +export function withRoutes( routes: ServerRoute[], - ...features: ServerRoutesFeature[] -): EnvironmentProviders { +): ServerRenderingFeature { const config: ServerRoutesConfig = { routes }; - const hasAppShell = features.some((f) => f.ɵkind === ServerRoutesFeatureKind.AppShell); - if (hasAppShell) { - config.appShellRoute = APP_SHELL_ROUTE; - } - - const providers: Provider[] = [ - { - provide: SERVER_ROUTES_CONFIG, - useValue: config, - }, - ]; - - for (const feature of features) { - providers.push(...feature.ɵproviders); - } - return makeEnvironmentProviders(providers); + return { + ɵkind: ServerRenderingFeatureKind.ServerRoutes, + ɵproviders: [ + { + provide: SERVER_ROUTES_CONFIG, + useValue: config, + }, + ], + }; } /** - * Configures the app shell route with the provided component. + * Configures the shell of the application. + * + * The app shell is a minimal, static HTML page that is served immediately, while the + * full Angular application loads in the background. This improves perceived performance + * by providing instant feedback to the user. + * + * This function configures the app shell route, which serves the provided component for + * requests that do not match any defined server routes. * - * The app shell serves as the main entry point for the application and is commonly used - * to enable server-side rendering (SSR) of the application shell. It handles requests - * that do not match any specific server route, providing a fallback mechanism and improving - * perceived performance during navigation. + * @param component - The Angular component to render for the app shell. Can be a direct + * component type or a dynamic import function. + * @returns A `ServerRenderingFeature` object configuring the app shell. * - * This configuration is particularly useful in applications leveraging Progressive Web App (PWA) - * patterns, such as service workers, to deliver a seamless user experience. + * @example + * ```ts + * import { provideServerRendering, withAppShell, withRoutes } from '@angular/ssr'; + * import { AppShellComponent } from './app-shell.component'; * - * @param component The Angular component to render for the app shell route. - * @returns A server routes feature configuration for the app shell. + * provideServerRendering( + * withRoutes(serverRoutes), + * withAppShell(AppShellComponent) + * ); + * ``` * - * @see {@link provideServerRouting} + * @example + * ```ts + * import { provideServerRendering, withAppShell, withRoutes } from '@angular/ssr'; + * + * provideServerRendering( + * withRoutes(serverRoutes), + * withAppShell(() => + * import('./app-shell.component').then((m) => m.AppShellComponent) + * ) + * ); + * ``` + * + * @see {@link provideServerRendering} * @see {@link https://angular.dev/ecosystem/service-workers/app-shell | App shell pattern on Angular.dev} */ export function withAppShell( component: Type | (() => Promise | DefaultExport>>), -): ServerRoutesFeature { +): ServerRenderingFeature { const routeConfig: Route = { path: APP_SHELL_ROUTE, }; @@ -267,13 +312,73 @@ export function withAppShell( } return { - ɵkind: ServerRoutesFeatureKind.AppShell, + ɵkind: ServerRenderingFeatureKind.AppShell, ɵproviders: [ { provide: ROUTES, useValue: routeConfig, multi: true, }, + provideEnvironmentInitializer(() => { + const config = inject(SERVER_ROUTES_CONFIG); + config.appShellRoute = APP_SHELL_ROUTE; + }), ], }; } + +/** + * Configures server-side rendering for an Angular application. + * + * This function sets up the necessary providers for server-side rendering, including + * support for server routes and app shell. It combines features configured using + * `withRoutes` and `withAppShell` to provide a comprehensive server-side rendering setup. + * + * @param features - Optional features to configure additional server rendering behaviors. + * @returns An `EnvironmentProviders` instance with the server-side rendering configuration. + * + * @example + * Basic example of how you can enable server-side rendering in your application + * when using the `bootstrapApplication` function: + * + * ```ts + * import { bootstrapApplication } from '@angular/platform-browser'; + * import { provideServerRendering, withRoutes, withAppShell } from '@angular/ssr'; + * import { AppComponent } from './app/app.component'; + * import { SERVER_ROUTES } from './app/app.server.routes'; + * import { AppShellComponent } from './app/app-shell.component'; + * + * bootstrapApplication(AppComponent, { + * providers: [ + * provideServerRendering( + * withRoutes(SERVER_ROUTES), + * withAppShell(AppShellComponent) + * ) + * ] + * }); + * ``` + * @see {@link withRoutes} configures server-side routing + * @see {@link withAppShell} configures the application shell + */ +export function provideServerRendering( + ...features: ServerRenderingFeature[] +): EnvironmentProviders { + let hasAppShell = false; + let hasServerRoutes = false; + const providers: (Provider | EnvironmentProviders)[] = [provideServerRenderingPlatformServer()]; + + for (const { ɵkind, ɵproviders } of features) { + hasAppShell ||= ɵkind === ServerRenderingFeatureKind.AppShell; + hasServerRoutes ||= ɵkind === ServerRenderingFeatureKind.ServerRoutes; + providers.push(...ɵproviders); + } + + if (!hasServerRoutes && hasAppShell) { + throw new Error( + `Configuration error: found 'withAppShell()' without 'withRoutes()' in the same call to 'provideServerRendering()'.` + + `The 'withAppShell()' function requires 'withRoutes()' to be used.`, + ); + } + + return makeEnvironmentProviders(providers); +} diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel index 8fad8bd45ca9..96905391055f 100644 --- a/packages/angular/ssr/test/BUILD.bazel +++ b/packages/angular/ssr/test/BUILD.bazel @@ -13,7 +13,6 @@ ts_project( "//:node_modules/@angular/compiler", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", - "//:node_modules/@angular/platform-server", "//:node_modules/@angular/router", "//:node_modules/@types/node", "//packages/angular/ssr", diff --git a/packages/angular/ssr/test/npm_package/package_spec.ts b/packages/angular/ssr/test/npm_package/package_spec.ts index 2bd37aeabf5b..c519c53f7b92 100644 --- a/packages/angular/ssr/test/npm_package/package_spec.ts +++ b/packages/angular/ssr/test/npm_package/package_spec.ts @@ -24,12 +24,6 @@ const CRITTERS_ACTUAL_LICENSE_FILE_PATH = join( 'third_party/beasties/THIRD_PARTY_LICENSES.txt', ); -/** - * Path to the golden reference license file for the Beasties library. - * This file is used as a reference for comparison and is located in the same directory as this script. - */ -const CRITTERS_GOLDEN_LICENSE_FILE_PATH = join(__dirname, 'THIRD_PARTY_LICENSES.txt.golden'); - describe('NPM Package Tests', () => { it('should not include the contents of third_party/beasties/index.js in the FESM bundle', async () => { const fesmFilePath = join(ANGULAR_SSR_PACKAGE_PATH, 'fesm2022/ssr.mjs'); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index b6d01398d7cc..92521972ac58 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -14,11 +14,10 @@ import { provideExperimentalZonelessChangeDetection, } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; -import { provideServerRendering } from '@angular/platform-server'; import { RouterOutlet, Routes, provideRouter } from '@angular/router'; import { destroyAngularServerApp } from '../src/app'; import { ServerAsset, setAngularAppManifest } from '../src/manifest'; -import { ServerRoute, provideServerRouting } from '../src/routes/route-config'; +import { ServerRoute, provideServerRendering, withRoutes } from '../src/routes/route-config'; @Component({ standalone: true, @@ -94,10 +93,9 @@ export function setAngularAppTestingManifest( bootstrap: async () => () => { return bootstrapApplication(rootComponent, { providers: [ - provideServerRendering(), provideExperimentalZonelessChangeDetection(), provideRouter(routes), - provideServerRouting(serverRoutes), + provideServerRendering(withRoutes(serverRoutes)), ...extraProviders, ], }); diff --git a/packages/schematics/angular/app-shell/index.ts b/packages/schematics/angular/app-shell/index.ts index 683ab4baba1c..66e188c4e45d 100644 --- a/packages/schematics/angular/app-shell/index.ts +++ b/packages/schematics/angular/app-shell/index.ts @@ -300,12 +300,12 @@ function addServerRoutingConfig(options: AppShellOptions, isStandalone: boolean) /** max */ undefined, /** recursive */ true, ).find( - (n) => ts.isIdentifier(n.expression) && n.expression.getText() === 'provideServerRouting', + (n) => ts.isIdentifier(n.expression) && n.expression.getText() === 'provideServerRendering', ); if (!functionCall) { throw new SchematicsException( - `Cannot find the "provideServerRouting" function call in "${configFilePath}".`, + `Cannot find the "provideServerRendering" function call in "${configFilePath}".`, ); } diff --git a/packages/schematics/angular/app-shell/index_spec.ts b/packages/schematics/angular/app-shell/index_spec.ts index bc110813a0be..9a33353a71e5 100644 --- a/packages/schematics/angular/app-shell/index_spec.ts +++ b/packages/schematics/angular/app-shell/index_spec.ts @@ -123,11 +123,11 @@ describe('App Shell Schematic', () => { expect(content).toMatch(/app-shell/); }); - it(`should update the 'provideServerRouting' call to include 'withAppShell'`, async () => { + it(`should update the 'provideServerRendering' call to include 'withAppShell'`, async () => { const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); const content = tree.readContent('/projects/bar/src/app/app.config.server.ts'); expect(tags.oneLine`${content}`).toContain( - tags.oneLine`provideServerRouting(serverRoutes, withAppShell(AppShell))`, + tags.oneLine`provideServerRendering(withRoutes(serverRoutes), withAppShell(AppShell))`, ); }); diff --git a/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template b/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template index 4fde3bf38675..bd711d72954a 100644 --- a/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template +++ b/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template @@ -1,13 +1,12 @@ import { NgModule } from '@angular/core'; -import { ServerModule } from '@angular/platform-server'; -import { provideServerRouting } from '@angular/ssr'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; import { App } from './app'; import { AppModule } from './app.module'; import { serverRoutes } from './app.routes.server'; @NgModule({ - imports: [AppModule, ServerModule], - providers: [provideServerRouting(serverRoutes)], + imports: [AppModule], + providers: [provideServerRendering(withRoutes(serverRoutes))], bootstrap: [App], }) export class AppServerModule {} diff --git a/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template b/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template index 012518913eed..41031f1165dd 100644 --- a/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template +++ b/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template @@ -1,13 +1,11 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; -import { provideServerRendering } from '@angular/platform-server'; -import { provideServerRouting } from '@angular/ssr'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; const serverConfig: ApplicationConfig = { providers: [ - provideServerRendering(), - provideServerRouting(serverRoutes) + provideServerRendering(withRoutes(serverRoutes)) ] }; diff --git a/packages/schematics/angular/server/files/server-builder/standalone-src/app/app.config.server.ts.template b/packages/schematics/angular/server/files/server-builder/standalone-src/app/app.config.server.ts.template index b4d57c94235f..05c29319d5c4 100644 --- a/packages/schematics/angular/server/files/server-builder/standalone-src/app/app.config.server.ts.template +++ b/packages/schematics/angular/server/files/server-builder/standalone-src/app/app.config.server.ts.template @@ -1,5 +1,5 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; -import { provideServerRendering } from '@angular/platform-server'; +import { provideServerRendering } from '@angular/ssr'; import { appConfig } from './app.config'; const serverConfig: ApplicationConfig = { From 40cac8471c9dffb9a587e57c2f65cfee27045290 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 18 Mar 2025 13:51:26 +0000 Subject: [PATCH 2/2] feat(@schematics/angular): add migrations for server rendering updates - Migrate imports of `provideServerRendering` from `@angular/platform-server` to `@angular/ssr`. - Update `provideServerRendering` to use `withRoutes` and remove `provideServerRouting` from `@angular/ssr`. --- .../migrations/migration-collection.json | 10 ++ .../migration.ts | 110 +++++++++++++++++ .../migration_spec.ts | 75 ++++++++++++ .../migration.ts | 114 ++++++++++++++++++ .../migration_spec.ts | 89 ++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration.ts create mode 100644 packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration_spec.ts create mode 100644 packages/schematics/angular/migrations/replace-provide-server-routing/migration.ts create mode 100644 packages/schematics/angular/migrations/replace-provide-server-routing/migration_spec.ts diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 14815a63d5a6..4afb4facc7a3 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -1,5 +1,15 @@ { "schematics": { + "replace-provide-server-rendering-import": { + "version": "20.0.0", + "factory": "./replace-provide-server-rendering-import/migration", + "description": "Migrate imports of 'provideServerRendering' from '@angular/platform-server' to '@angular/ssr'." + }, + "replace-provide-server-routing": { + "version": "20.0.0", + "factory": "./replace-provide-server-routing/migration", + "description": "Migrate 'provideServerRendering' to use 'withRoutes' and remove 'provideServerRouting' from '@angular/ssr'." + }, "use-application-builder": { "version": "20.0.0", "factory": "./use-application-builder/migration", diff --git a/packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration.ts b/packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration.ts new file mode 100644 index 000000000000..dcca288e63d4 --- /dev/null +++ b/packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration.ts @@ -0,0 +1,110 @@ +/** + * @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.dev/license + */ + +import { DirEntry, Rule } from '@angular-devkit/schematics'; +import * as ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { NodeDependencyType, addPackageJsonDependency } from '../../utility/dependencies'; +import { latestVersions } from '../../utility/latest-versions'; + +function* visit(directory: DirEntry): IterableIterator<[fileName: string, contents: string]> { + for (const path of directory.subfiles) { + if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + if ( + content.includes('provideServerRendering') && + content.includes('@angular/platform-server') + ) { + // Only need to rename the import so we can just string replacements. + yield [entry.path, content.toString()]; + } + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +export default function (): Rule { + return async (tree) => { + addPackageJsonDependency(tree, { + name: '@angular/ssr', + version: latestVersions.AngularSSR, + type: NodeDependencyType.Default, + overwrite: false, + }); + + for (const [filePath, content] of visit(tree.root)) { + let updatedContent = content; + const ssrImports = new Set(); + const platformServerImports = new Set(); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + + sourceFile.forEachChild((node) => { + if (ts.isImportDeclaration(node)) { + const moduleSpecifier = node.moduleSpecifier.getText(sourceFile); + if (moduleSpecifier.includes('@angular/platform-server')) { + const importClause = node.importClause; + if ( + importClause && + importClause.namedBindings && + ts.isNamedImports(importClause.namedBindings) + ) { + const namedImports = importClause.namedBindings.elements.map((e) => + e.getText(sourceFile), + ); + namedImports.forEach((importName) => { + if (importName === 'provideServerRendering') { + ssrImports.add(importName); + } else { + platformServerImports.add(importName); + } + }); + } + updatedContent = updatedContent.replace(node.getFullText(sourceFile), ''); + } else if (moduleSpecifier.includes('@angular/ssr')) { + const importClause = node.importClause; + if ( + importClause && + importClause.namedBindings && + ts.isNamedImports(importClause.namedBindings) + ) { + importClause.namedBindings.elements.forEach((e) => { + ssrImports.add(e.getText(sourceFile)); + }); + } + updatedContent = updatedContent.replace(node.getFullText(sourceFile), ''); + } + } + }); + + if (platformServerImports.size > 0) { + updatedContent = + `import { ${Array.from(platformServerImports).sort().join(', ')} } from '@angular/platform-server';\n` + + updatedContent; + } + + if (ssrImports.size > 0) { + updatedContent = + `import { ${Array.from(ssrImports).sort().join(', ')} } from '@angular/ssr';\n` + + updatedContent; + } + + if (content !== updatedContent) { + tree.overwrite(filePath, updatedContent); + } + } + }; +} diff --git a/packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration_spec.ts b/packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration_spec.ts new file mode 100644 index 000000000000..8d4060a9dd2a --- /dev/null +++ b/packages/schematics/angular/migrations/replace-provide-server-rendering-import/migration_spec.ts @@ -0,0 +1,75 @@ +/** + * @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.dev/license + */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +describe(`Migration to use the 'provideServerRendering' from '@angular/ssr'`, () => { + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: UnitTestTree; + const schematicName = 'replace-provide-server-rendering-import'; + + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: { + '@angular/ssr': '0.0.0', + }, + }), + ); + }); + + it('should replace provideServerRendering with @angular/ssr and keep other imports', async () => { + tree.create( + 'test.ts', + `import { provideServerRendering, otherFunction } from '@angular/platform-server';`, + ); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readContent('test.ts'); + expect(content).toContain("import { provideServerRendering } from '@angular/ssr';"); + expect(content).toContain("import { otherFunction } from '@angular/platform-server';"); + }); + + it('should not replace provideServerRendering that is imported from @angular/ssr', async () => { + tree.create( + 'test.ts', + ` + import { otherFunction } from '@angular/platform-server'; + import { provideServerRendering, provideServerRouting } from '@angular/ssr'; + `, + ); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readContent('test.ts'); + expect(content).toContain( + "import { provideServerRendering, provideServerRouting } from '@angular/ssr';", + ); + expect(content).toContain("import { otherFunction } from '@angular/platform-server';"); + }); + + it('should merge with existing @angular/ssr imports', async () => { + tree.create( + 'test.ts', + ` + import { provideServerRouting } from '@angular/ssr'; + import { provideServerRendering } from '@angular/platform-server'; + `, + ); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readContent('test.ts'); + expect(content).toContain( + "import { provideServerRendering, provideServerRouting } from '@angular/ssr';", + ); + expect(content.match(/@angular\/ssr/g) || []).toHaveSize(1); + }); +}); diff --git a/packages/schematics/angular/migrations/replace-provide-server-routing/migration.ts b/packages/schematics/angular/migrations/replace-provide-server-routing/migration.ts new file mode 100644 index 000000000000..8fc662b6b69a --- /dev/null +++ b/packages/schematics/angular/migrations/replace-provide-server-routing/migration.ts @@ -0,0 +1,114 @@ +/** + * @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.dev/license + */ + +import { DirEntry, Rule } from '@angular-devkit/schematics'; +import * as ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { getPackageJsonDependency } from '../../utility/dependencies'; + +function* visit(directory: DirEntry): IterableIterator<[fileName: string, contents: string]> { + for (const path of directory.subfiles) { + if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + if (content.includes('provideServerRouting') && content.includes('@angular/ssr')) { + // Only need to rename the import so we can just string replacements. + yield [entry.path, content.toString()]; + } + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +export default function (): Rule { + return async (tree) => { + if (!getPackageJsonDependency(tree, '@angular/ssr')) { + return; + } + + for (const [filePath, content] of visit(tree.root)) { + const recorder = tree.beginUpdate(filePath); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + + function visit(node: ts.Node) { + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === 'providers' && + ts.isArrayLiteralExpression(node.initializer) + ) { + const providersArray = node.initializer; + const newProviders = providersArray.elements + .filter((el) => { + return !( + ts.isCallExpression(el) && + ts.isIdentifier(el.expression) && + el.expression.text === 'provideServerRendering' + ); + }) + .map((el) => { + if ( + ts.isCallExpression(el) && + ts.isIdentifier(el.expression) && + el.expression.text === 'provideServerRouting' + ) { + const [withRouteVal, ...others] = el.arguments.map((arg) => arg.getText()); + + return `provideServerRendering(withRoutes(${withRouteVal})${others.length ? ', ' + others.join(', ') : ''})`; + } + + return el.getText(); + }); + + // Update the 'providers' array in the source file + recorder.remove(providersArray.getStart(), providersArray.getWidth()); + recorder.insertRight(providersArray.getStart(), `[${newProviders.join(', ')}]`); + } + + ts.forEachChild(node, visit); + } + + // Visit all nodes to update 'providers' + visit(sourceFile); + + // Update imports by removing 'provideServerRouting' + const importDecl = sourceFile.statements.find( + (stmt) => + ts.isImportDeclaration(stmt) && + ts.isStringLiteral(stmt.moduleSpecifier) && + stmt.moduleSpecifier.text === '@angular/ssr', + ) as ts.ImportDeclaration | undefined; + + if (importDecl?.importClause?.namedBindings) { + const namedBindings = importDecl?.importClause.namedBindings; + + if (ts.isNamedImports(namedBindings)) { + const elements = namedBindings.elements; + const updatedElements = elements + .map((el) => el.getText()) + .filter((x) => x !== 'provideServerRouting'); + + updatedElements.push('withRoutes'); + + recorder.remove(namedBindings.getStart(), namedBindings.getWidth()); + recorder.insertLeft(namedBindings.getStart(), `{ ${updatedElements.sort().join(', ')} }`); + } + } + + tree.commitUpdate(recorder); + } + }; +} diff --git a/packages/schematics/angular/migrations/replace-provide-server-routing/migration_spec.ts b/packages/schematics/angular/migrations/replace-provide-server-routing/migration_spec.ts new file mode 100644 index 000000000000..a0ab33aa15f1 --- /dev/null +++ b/packages/schematics/angular/migrations/replace-provide-server-routing/migration_spec.ts @@ -0,0 +1,89 @@ +/** + * @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.dev/license + */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +describe(`Migration to replace 'provideServerRouting' with 'provideServerRendering' from '@angular/ssr'`, () => { + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + const schematicName = 'replace-provide-server-routing'; + let tree: UnitTestTree; + + beforeEach(async () => { + tree = new UnitTestTree(new EmptyTree()); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: { + '@angular/ssr': '0.0.0', + }, + }), + ); + + tree.create( + 'src/app/app.config.ts', + ` + import { ApplicationConfig } from '@angular/core'; + import { provideServerRendering, provideServerRouting } from '@angular/ssr'; + import { serverRoutes } from './app.routes'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + provideServerRouting(serverRoutes) + ] + }; + `, + ); + }); + + it('should add "withRoutes" to the import statement', async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readContent('src/app/app.config.ts'); + + expect(content).toContain(`import { provideServerRendering, withRoutes } from '@angular/ssr';`); + }); + + it('should remove "provideServerRouting" and update "provideServerRendering"', async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readContent('src/app/app.config.ts'); + + expect(content).toContain(`providers: [provideServerRendering(withRoutes(serverRoutes))]`); + expect(content).not.toContain(`provideServerRouting(serverRoutes)`); + }); + + it('should correctly handle provideServerRouting with extra arguments', async () => { + tree.overwrite( + 'src/app/app.config.ts', + ` + import { ApplicationConfig } from '@angular/core'; + import { provideServerRendering, provideServerRouting } from '@angular/ssr'; + import { serverRoutes } from './app.routes'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + provideServerRouting(serverRoutes, withAppShell(AppShellComponent)) + ] + }; + `, + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readContent('src/app/app.config.ts'); + + expect(content).toContain( + `providers: [provideServerRendering(withRoutes(serverRoutes), withAppShell(AppShellComponent))]`, + ); + expect(content).not.toContain(`provideServerRouting(serverRoutes)`); + }); +});