diff --git a/goldens/public-api/platform-server/index.md b/goldens/public-api/platform-server/index.md index 6322ab4d07e..72c2476610b 100644 --- a/goldens/public-api/platform-server/index.md +++ b/goldens/public-api/platform-server/index.md @@ -4,6 +4,7 @@ ```ts +import { ApplicationRef } from '@angular/core'; import { EnvironmentProviders } from '@angular/core'; import * as i0 from '@angular/core'; import * as i1 from '@angular/common/http'; @@ -47,6 +48,13 @@ export class PlatformState { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export function renderApplication(bootstrap: () => Promise, options: { + document?: string | Document; + url?: string; + platformProviders?: Provider[]; +}): Promise; + // @public export function renderApplication(rootComponent: Type, options: { appId: string; diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 45ddfb5c345..cd8a0b19dc8 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -219,6 +219,7 @@ export { export { setDocument as ɵsetDocument } from './render3/interfaces/document'; +export { getComponentDef as ɵgetComponentDef} from './render3/definition'; export { compileComponent as ɵcompileComponent, compileDirective as ɵcompileDirective, @@ -281,4 +282,5 @@ export { noSideEffects as ɵnoSideEffects, } from './util/closure'; + // clang-format on diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index 437e849c697..0ddc4915890 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ApplicationRef, EnvironmentProviders, importProvidersFrom, InjectionToken, NgModuleRef, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵinternalCreateApplication as internalCreateApplication, ɵisPromise} from '@angular/core'; +import {ApplicationRef, EnvironmentProviders, importProvidersFrom, InjectionToken, NgModuleRef, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵgetComponentDef as getComponentDef, ɵinternalCreateApplication as internalCreateApplication, ɵisPromise} from '@angular/core'; import {BrowserModule, ɵTRANSITION_ID} from '@angular/platform-browser'; import {first} from 'rxjs/operators'; @@ -155,9 +155,35 @@ export function renderModule(moduleType: Type, options: { /** * Bootstraps an instance of an Angular application and renders it to a string. + + * ```typescript + * const bootstrap = () => bootstrapApplication(RootComponent, appConfig); + * const output: string = await renderApplication(bootstrap); + * ``` * - * Note: the root component passed into this function *must* be a standalone one (should have the - * `standalone: true` flag in the `@Component` decorator config). + * @param bootstrap A method that when invoked returns a promise that returns an `ApplicationRef` + * instance once resolved. + * @param options Additional configuration for the render operation: + * - `document` - the document of the page to render, either as an HTML string or + * as a reference to the `document` instance. + * - `url` - the URL for the current render request. + * - `platformProviders` - the platform level providers for the current render request. + * + * @returns A Promise, that returns serialized (to a string) rendered page, once resolved. + * + * @publicApi + * @developerPreview + */ +export function renderApplication(bootstrap: () => Promise, options: { + document?: string|Document, + url?: string, + platformProviders?: Provider[], +}): Promise; +/** + * Bootstraps an instance of an Angular application and renders it to a string. + * + * Note: the root component passed into this function *must* be a standalone one (should have + * the `standalone: true` flag in the `@Component` decorator config). * * ```typescript * @Component({ @@ -186,18 +212,40 @@ export function renderModule(moduleType: Type, options: { * @developerPreview */ export function renderApplication(rootComponent: Type, options: { + /** @deprecated use `APP_ID` token to set the application ID. */ appId: string, document?: string|Document, url?: string, providers?: Array, platformProviders?: Provider[], -}): Promise { - const {document, url, platformProviders, appId} = options; +}): Promise; +export function renderApplication( + rootComponentOrBootstrapFn: Type|(() => Promise), options: { + appId?: string, + document?: string|Document, + url?: string, + providers?: Array, + platformProviders?: Provider[], + }): Promise { + const {document, url, platformProviders, appId = ''} = options; const platform = _getPlatform(platformDynamicServer, {document, url, platformProviders}); + + if (isBootstrapFn(rootComponentOrBootstrapFn)) { + return _render(platform, rootComponentOrBootstrapFn()); + } + const appProviders = [ importProvidersFrom(BrowserModule.withServerTransition({appId})), importProvidersFrom(ServerModule), ...(options.providers ?? []), ]; - return _render(platform, internalCreateApplication({rootComponent, appProviders})); + + return _render( + platform, + internalCreateApplication({rootComponent: rootComponentOrBootstrapFn, appProviders})); +} + +function isBootstrapFn(value: unknown): value is() => Promise { + // We can differentiate between a component and a bootstrap function by reading `cmp`: + return typeof value === 'function' && !getComponentDef(value); } diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 7ed0fe1def1..947cb3cf691 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -10,9 +10,9 @@ import {animate, AnimationBuilder, state, style, transition, trigger} from '@ang import {DOCUMENT, isPlatformServer, PlatformLocation, ɵgetDOM as getDOM} from '@angular/common'; import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; -import {ApplicationRef, Component, destroyPlatform, getPlatform, HostListener, Inject, inject as coreInject, Injectable, Input, NgModule, NgZone, PLATFORM_ID, ViewEncapsulation} from '@angular/core'; +import {ApplicationRef, Component, destroyPlatform, getPlatform, HostListener, importProvidersFrom, Inject, inject as coreInject, Injectable, Input, NgModule, NgZone, PLATFORM_ID, ViewEncapsulation} from '@angular/core'; import {TestBed, waitForAsync} from '@angular/core/testing'; -import {BrowserModule, makeStateKey, Title, TransferState} from '@angular/platform-browser'; +import {bootstrapApplication, BrowserModule, makeStateKey, Title, TransferState} from '@angular/platform-browser'; import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, platformDynamicServer, PlatformState, renderModule, ServerModule} from '@angular/platform-server'; import {Observable} from 'rxjs'; import {first} from 'rxjs/operators'; @@ -203,6 +203,13 @@ function createMyAsyncServerApp(standalone: boolean) { const MyAsyncServerApp = createMyAsyncServerApp(false); const MyAsyncServerAppStandalone = createMyAsyncServerApp(true); +const boostrapMyAsyncServerAppStandalone = () => bootstrapApplication(MyAsyncServerAppStandalone, { + providers: [ + importProvidersFrom(BrowserModule.withServerTransition({appId: 'simple-cmp'})), + importProvidersFrom(ServerModule), + ] +}); + @NgModule({ declarations: [MyAsyncServerApp], imports: [BrowserModule.withServerTransition({appId: 'async-server'}), ServerModule], @@ -702,6 +709,41 @@ describe('platform-server integration', () => { }); })); + it(`using renderApplication with boostrapping function call works`, waitForAsync(() => { + const document = TestBed.inject(DOCUMENT); + + // Append root element based on the app selector. + const rootEl = document.createElement('app'); + document.body.appendChild(rootEl); + + // Append a special marker to verify that we use a correct instance + // of the document for rendering. + const markerEl = document.createComment('test marker'); + document.body.appendChild(markerEl); + + const platformProviders = [{ + provide: SERVER_CONTEXT, + useValue: 'ssr', + }]; + + const render = + renderApplication(boostrapMyAsyncServerAppStandalone, {document, platformProviders}); + + render + .then(output => { + expect(output).toBe( + 'fakeTitle' + + '' + + 'Works!

fine

' + + ''); + called = true; + }) + .finally(() => { + rootEl.remove(); + markerEl.remove(); + }); + })); + // Run the set of tests with regular and standalone components. [true, false].forEach((isStandalone: boolean) => { it(`using ${isStandalone ? 'renderApplication' : 'renderModule'} should work`,