From b9ca7ce09f8e0ac23e509c0ede27bf3e6d77abf2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 17 Aug 2023 07:59:15 -0700 Subject: [PATCH] feat(angular): add flag to include hydration when setting up ssr (#18675) --- .../angular/generators/setup-ssr.json | 4 + .../generators/setup-ssr/lib/add-hydration.ts | 73 ++++++++++ .../src/generators/setup-ssr/lib/index.ts | 1 + .../setup-ssr/lib/normalize-options.ts | 1 + .../setup-ssr/lib/validate-options.ts | 17 +++ .../src/generators/setup-ssr/schema.d.ts | 1 + .../src/generators/setup-ssr/schema.json | 4 + .../generators/setup-ssr/setup-ssr.spec.ts | 132 ++++++++++++++---- .../src/generators/setup-ssr/setup-ssr.ts | 22 ++- 9 files changed, 221 insertions(+), 34 deletions(-) create mode 100644 packages/angular/src/generators/setup-ssr/lib/add-hydration.ts diff --git a/docs/generated/packages/angular/generators/setup-ssr.json b/docs/generated/packages/angular/generators/setup-ssr.json index 27405539cf29e..a891887dd37af 100644 --- a/docs/generated/packages/angular/generators/setup-ssr.json +++ b/docs/generated/packages/angular/generators/setup-ssr.json @@ -53,6 +53,10 @@ "type": "boolean", "description": "Use Standalone Components to bootstrap SSR. _Note: This is only supported in Angular versions >= 14.1.0_." }, + "hydration": { + "type": "boolean", + "description": "Set up Hydration for the SSR application. _Note: This is only supported in Angular versions >= 16.0.0_." + }, "skipFormat": { "type": "boolean", "description": "Skip formatting the workspace after the generator completes.", diff --git a/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts b/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts new file mode 100644 index 0000000000000..131070c7a442a --- /dev/null +++ b/packages/angular/src/generators/setup-ssr/lib/add-hydration.ts @@ -0,0 +1,73 @@ +import { + joinPathFragments, + readProjectConfiguration, + type Tree, +} from '@nx/devkit'; +import { type Schema } from '../schema'; +import { + addProviderToAppConfig, + addProviderToModule, +} from '../../../utils/nx-devkit/ast-utils'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; +import { SourceFile } from 'typescript'; +import { insertImport } from '@nx/js'; + +let tsModule: typeof import('typescript'); + +export function addHydration(tree: Tree, options: Schema) { + const projectConfig = readProjectConfiguration(tree, options.project); + + if (!tsModule) { + tsModule = ensureTypescript(); + } + const addImport = ( + source: SourceFile, + symbolName: string, + packageName: string, + filePath: string, + isDefault = false + ): SourceFile => { + return insertImport( + tree, + source, + filePath, + symbolName, + packageName, + isDefault + ); + }; + + const pathToClientConfigFile = options.standalone + ? joinPathFragments(projectConfig.sourceRoot, 'app/app.config.ts') + : joinPathFragments(projectConfig.sourceRoot, 'app/app.module.ts'); + + const sourceText = tree.read(pathToClientConfigFile, 'utf-8'); + let sourceFile = tsModule.createSourceFile( + pathToClientConfigFile, + sourceText, + tsModule.ScriptTarget.Latest, + true + ); + + sourceFile = addImport( + sourceFile, + 'provideClientHydration', + '@angular/platform-browser', + pathToClientConfigFile + ); + + if (options.standalone) { + addProviderToAppConfig( + tree, + pathToClientConfigFile, + 'provideClientHydration()' + ); + } else { + addProviderToModule( + tree, + sourceFile, + pathToClientConfigFile, + 'provideClientHydration()' + ); + } +} diff --git a/packages/angular/src/generators/setup-ssr/lib/index.ts b/packages/angular/src/generators/setup-ssr/lib/index.ts index 5f8e3dfd0b078..f1cb83e8e5f78 100644 --- a/packages/angular/src/generators/setup-ssr/lib/index.ts +++ b/packages/angular/src/generators/setup-ssr/lib/index.ts @@ -3,3 +3,4 @@ export * from './normalize-options'; export * from './update-app-module'; export * from './update-project-config'; export * from './validate-options'; +export * from './add-hydration'; diff --git a/packages/angular/src/generators/setup-ssr/lib/normalize-options.ts b/packages/angular/src/generators/setup-ssr/lib/normalize-options.ts index a3053228a9ecb..958b16d88e077 100644 --- a/packages/angular/src/generators/setup-ssr/lib/normalize-options.ts +++ b/packages/angular/src/generators/setup-ssr/lib/normalize-options.ts @@ -15,5 +15,6 @@ export function normalizeOptions(tree: Tree, options: Schema) { rootModuleClassName: options.rootModuleClassName ?? 'AppServerModule', skipFormat: options.skipFormat ?? false, standalone: options.standalone ?? isStandaloneApp, + hydration: options.hydration ?? false, }; } diff --git a/packages/angular/src/generators/setup-ssr/lib/validate-options.ts b/packages/angular/src/generators/setup-ssr/lib/validate-options.ts index 90d5836de3783..6f8128d2c2a3a 100644 --- a/packages/angular/src/generators/setup-ssr/lib/validate-options.ts +++ b/packages/angular/src/generators/setup-ssr/lib/validate-options.ts @@ -1,11 +1,28 @@ import type { Tree } from '@nx/devkit'; +import { stripIndents } from '@nx/devkit'; import { validateProject, validateStandaloneOption, } from '../../utils/validations'; import type { Schema } from '../schema'; +import { getInstalledAngularVersionInfo } from '../../utils/version-utils'; +import { lt } from 'semver'; export function validateOptions(tree: Tree, options: Schema): void { validateProject(tree, options.project); validateStandaloneOption(tree, options.standalone); + validateHydrationOption(tree, options.hydration); +} + +function validateHydrationOption(tree: Tree, hydration: boolean): void { + if (!hydration) { + return; + } + + const installedAngularVersion = getInstalledAngularVersionInfo(tree).version; + + if (lt(installedAngularVersion, '16.0.0')) { + throw new Error(stripIndents`The "hydration" option is only supported in Angular >= 16.0.0. You are currently using "${installedAngularVersion}". + You can resolve this error by removing the "hydration" option or by migrating to Angular 16.0.0.`); + } } diff --git a/packages/angular/src/generators/setup-ssr/schema.d.ts b/packages/angular/src/generators/setup-ssr/schema.d.ts index 7846fc22fbd1f..af319e5a9e917 100644 --- a/packages/angular/src/generators/setup-ssr/schema.d.ts +++ b/packages/angular/src/generators/setup-ssr/schema.d.ts @@ -7,5 +7,6 @@ export interface Schema { rootModuleFileName?: string; rootModuleClassName?: string; standalone?: boolean; + hydration?: boolean; skipFormat?: boolean; } diff --git a/packages/angular/src/generators/setup-ssr/schema.json b/packages/angular/src/generators/setup-ssr/schema.json index d88d75be9367d..a13fb1ec9a55f 100644 --- a/packages/angular/src/generators/setup-ssr/schema.json +++ b/packages/angular/src/generators/setup-ssr/schema.json @@ -53,6 +53,10 @@ "type": "boolean", "description": "Use Standalone Components to bootstrap SSR. _Note: This is only supported in Angular versions >= 14.1.0_." }, + "hydration": { + "type": "boolean", + "description": "Set up Hydration for the SSR application. _Note: This is only supported in Angular versions >= 16.0.0_." + }, "skipFormat": { "type": "boolean", "description": "Skip formatting the workspace after the generator completes.", diff --git a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts index 1805b60112b6d..6150b73b96643 100644 --- a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts +++ b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts @@ -237,6 +237,78 @@ describe('setupSSR', () => { `); }); + it('should add hydration correctly for NgModule apps', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + await generateTestApplication(tree, { + name: 'app1', + }); + + // ACT + await setupSsr(tree, { project: 'app1', hydration: true }); + + // ASSERT + expect(tree.read('apps/app1/src/app/app.module.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { NgModule } from '@angular/core'; + import { + BrowserModule, + provideClientHydration, + } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { NxWelcomeComponent } from './nx-welcome.component'; + + @NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule], + providers: [provideClientHydration()], + bootstrap: [AppComponent], + }) + export class AppModule {} + " + `); + }); + + it('should add hydration correctly to standalone', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + await generateTestApplication(tree, { + name: 'app1', + standalone: true, + }); + + // ACT + await setupSsr(tree, { project: 'app1', hydration: true }); + + // ASSERT + expect(tree.read('apps/app1/src/app/app.config.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { ApplicationConfig } from '@angular/core'; + import { provideClientHydration } from '@angular/platform-browser'; + + export const appConfig: ApplicationConfig = { + providers: [provideClientHydration()], + }; + " + `); + + expect(tree.read('apps/app1/src/app/app.config.server.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [provideServerRendering()], + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + " + `); + }); + describe('compat', () => { it('should install the correct versions when using older versions of Angular', async () => { // ARRANGE @@ -319,20 +391,20 @@ describe('setupSSR', () => { // ASSERT expect(tree.read('apps/app1/src/app/app.module.ts', 'utf-8')) .toMatchInlineSnapshot(` - "import { NgModule } from '@angular/core'; - import { BrowserModule } from '@angular/platform-browser'; - import { AppComponent } from './app.component'; - import { NxWelcomeComponent } from './nx-welcome.component'; - - @NgModule({ - declarations: [AppComponent, NxWelcomeComponent], - imports: [BrowserModule.withServerTransition({ appId: 'serverApp' })], - providers: [], - bootstrap: [AppComponent], - }) - export class AppModule {} - " - `); + "import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { NxWelcomeComponent } from './nx-welcome.component'; + + @NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule.withServerTransition({ appId: 'serverApp' })], + providers: [], + bootstrap: [AppComponent], + }) + export class AppModule {} + " + `); }); it('should wrap bootstrap call for Angular versions lower than 15.2', async () => { @@ -352,22 +424,22 @@ describe('setupSSR', () => { // ASSERT expect(tree.read('apps/app1/src/main.ts', 'utf-8')) .toMatchInlineSnapshot(` - "import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - import { AppModule } from './app/app.module'; - - function bootstrap() { - platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch((err) => console.error(err)); - } - - if (document.readyState !== 'loading') { - bootstrap(); - } else { - document.addEventListener('DOMContentLoaded', bootstrap); - } - " - `); + "import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + function bootstrap() { + platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); + } + + if (document.readyState !== 'loading') { + bootstrap(); + } else { + document.addEventListener('DOMContentLoaded', bootstrap); + } + " + `); }); }); }); diff --git a/packages/angular/src/generators/setup-ssr/setup-ssr.ts b/packages/angular/src/generators/setup-ssr/setup-ssr.ts index 49e7242f22f62..2d55c73b91946 100644 --- a/packages/angular/src/generators/setup-ssr/setup-ssr.ts +++ b/packages/angular/src/generators/setup-ssr/setup-ssr.ts @@ -4,8 +4,12 @@ import { formatFiles, installPackagesTask, } from '@nx/devkit'; -import { versions } from '../utils/version-utils'; import { + getInstalledPackageVersionInfo, + versions, +} from '../utils/version-utils'; +import { + addHydration, generateSSRFiles, normalizeOptions, updateAppModule, @@ -25,16 +29,26 @@ export async function setupSsr(tree: Tree, schema: Schema) { updateAppModule(tree, options); } + if (options.hydration) { + addHydration(tree, options); + } + const pkgVersions = versions(tree); addDependenciesToPackageJson( tree, { - '@nguniversal/express-engine': pkgVersions.ngUniversalVersion, - '@angular/platform-server': pkgVersions.angularVersion, + '@nguniversal/express-engine': + getInstalledPackageVersionInfo(tree, '@nguniversal/express-engine') + ?.version ?? pkgVersions.ngUniversalVersion, + '@angular/platform-server': + getInstalledPackageVersionInfo(tree, '@angular/platform-server') + ?.version ?? pkgVersions.angularVersion, }, { - '@nguniversal/builders': pkgVersions.ngUniversalVersion, + '@nguniversal/builders': + getInstalledPackageVersionInfo(tree, '@nguniversal/builders') + ?.version ?? pkgVersions.ngUniversalVersion, } );