diff --git a/modules/@angular/platform-browser/src/browser.ts b/modules/@angular/platform-browser/src/browser.ts index d41e025faf..c75127a44e 100644 --- a/modules/@angular/platform-browser/src/browser.ts +++ b/modules/@angular/platform-browser/src/browser.ts @@ -7,7 +7,7 @@ */ import {CommonModule, PlatformLocation} from '@angular/common'; -import {ApplicationModule, ErrorHandler, NgModule, Optional, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactoryV2, RootRenderer, Sanitizer, SkipSelf, Testability, createPlatformFactory, platformCore} from '@angular/core'; +import {APP_ID, ApplicationModule, ErrorHandler, ModuleWithProviders, NgModule, Optional, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactoryV2, RootRenderer, Sanitizer, SkipSelf, Testability, createPlatformFactory, platformCore} from '@angular/core'; import {AnimationDriver} from '../src/dom/animation_driver'; import {WebAnimationsDriver} from '../src/dom/web_animations_driver'; @@ -15,6 +15,7 @@ import {WebAnimationsDriver} from '../src/dom/web_animations_driver'; import {BrowserDomAdapter} from './browser/browser_adapter'; import {BrowserPlatformLocation} from './browser/location/browser_platform_location'; import {Meta} from './browser/meta'; +import {SERVER_TRANSITION_PROVIDERS, TRANSITION_ID} from './browser/server-transition'; import {BrowserGetTestability} from './browser/testability'; import {Title} from './browser/title'; import {ELEMENT_PROBE_PROVIDERS} from './dom/debug/ng_probe'; @@ -106,4 +107,22 @@ export class BrowserModule { `BrowserModule has already been loaded. If you need access to common directives such as NgIf and NgFor from a lazy loaded module, import CommonModule instead.`); } } + + /** + * Configures a browser-based application to transition from a server-rendered app, if + * one is present on the page. The specified parameters must include an application id, + * which must match between the client and server applications. + * + * @experimental + */ + static withServerTransition(params: {appId: string}): ModuleWithProviders { + return { + ngModule: BrowserModule, + providers: [ + {provide: APP_ID, useValue: params.appId}, + {provide: TRANSITION_ID, useExisting: APP_ID}, + SERVER_TRANSITION_PROVIDERS, + ], + }; + } } diff --git a/modules/@angular/platform-browser/src/browser/server-transition.ts b/modules/@angular/platform-browser/src/browser/server-transition.ts new file mode 100644 index 0000000000..83d3c12518 --- /dev/null +++ b/modules/@angular/platform-browser/src/browser/server-transition.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google Inc. 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 {APP_INITIALIZER, Inject, InjectionToken, Provider} from '@angular/core'; + +import {getDOM} from '../dom/dom_adapter'; +import {DOCUMENT} from '../dom/dom_tokens'; + +/** + * An id that identifies a particular application being bootstrapped, that should + * match across the client/server boundary. + */ +export const TRANSITION_ID = new InjectionToken('TRANSITION_ID'); + +export function bootstrapListenerFactory(transitionId: string, document: any) { + const factory = () => { + const dom = getDOM(); + const styles: any[] = + Array.prototype.slice.apply(dom.querySelectorAll(document, `style[ng-transition]`)); + styles.filter(el => dom.getAttribute(el, 'ng-transition') === transitionId) + .forEach(el => dom.remove(el)); + }; + return factory; +} + +export const SERVER_TRANSITION_PROVIDERS: Provider[] = [ + { + provide: APP_INITIALIZER, + useFactory: bootstrapListenerFactory, + deps: [TRANSITION_ID, DOCUMENT], + multi: true + }, +]; diff --git a/modules/@angular/platform-browser/src/private_export.ts b/modules/@angular/platform-browser/src/private_export.ts index 355e903a3b..cb72951792 100644 --- a/modules/@angular/platform-browser/src/private_export.ts +++ b/modules/@angular/platform-browser/src/private_export.ts @@ -9,6 +9,7 @@ export {BROWSER_SANITIZATION_PROVIDERS as ɵBROWSER_SANITIZATION_PROVIDERS, INTERNAL_BROWSER_PLATFORM_PROVIDERS as ɵINTERNAL_BROWSER_PLATFORM_PROVIDERS, initDomAdapter as ɵinitDomAdapter} from './browser'; export {BrowserDomAdapter as ɵBrowserDomAdapter} from './browser/browser_adapter'; export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './browser/location/browser_platform_location'; +export {TRANSITION_ID as ɵTRANSITION_ID} from './browser/server-transition'; export {BrowserGetTestability as ɵBrowserGetTestability} from './browser/testability'; export {ELEMENT_PROBE_PROVIDERS as ɵELEMENT_PROBE_PROVIDERS} from './dom/debug/ng_probe'; export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom/dom_adapter'; diff --git a/modules/@angular/platform-browser/test/browser/bootstrap_spec.ts b/modules/@angular/platform-browser/test/browser/bootstrap_spec.ts index 5142951a33..13c7ba8f22 100644 --- a/modules/@angular/platform-browser/test/browser/bootstrap_spec.ts +++ b/modules/@angular/platform-browser/test/browser/bootstrap_spec.ts @@ -336,6 +336,44 @@ export function main() { }); })); + it('should remove styles when transitioning from a server render', + inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + + @Component({ + selector: 'root', + template: 'root', + }) + class RootCmp { + } + + @NgModule({ + bootstrap: [RootCmp], + declarations: [RootCmp], + imports: [BrowserModule.withServerTransition({appId: 'my-app'})], + }) + class TestModule { + } + + // First, set up styles to be removed. + const dom = getDOM(); + const platform = platformBrowserDynamic(); + const document = platform.injector.get(DOCUMENT); + const style = dom.createElement('style', document); + dom.setAttribute(style, 'ng-transition', 'my-app'); + dom.appendChild(document.head, style); + + const root = dom.createElement('root', document); + dom.appendChild(document.body, root); + + platform.bootstrapModule(TestModule).then(() => { + const styles: HTMLElement[] = + Array.prototype.slice.apply(dom.getElementsByTagName(document, 'style') || []); + styles.forEach( + style => { expect(dom.getAttribute(style, 'ng-transition')).not.toBe('my-app'); }); + async.done(); + }); + })); + it('should register each application with the testability registry', inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { const refPromise1: Promise> = bootstrap(HelloRootCmp, testProviders); diff --git a/modules/@angular/platform-server/src/server.ts b/modules/@angular/platform-server/src/server.ts index 1b4455d008..c04cca086c 100644 --- a/modules/@angular/platform-server/src/server.ts +++ b/modules/@angular/platform-server/src/server.ts @@ -8,7 +8,7 @@ import {PlatformLocation} from '@angular/common'; import {platformCoreDynamic} from '@angular/compiler'; -import {APP_BOOTSTRAP_LISTENER, Injectable, InjectionToken, Injector, NgModule, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactoryV2, RootRenderer, createPlatformFactory, isDevMode, platformCore, ɵALLOW_MULTIPLE_PLATFORMS as ALLOW_MULTIPLE_PLATFORMS, ɵDebugDomRootRenderer as DebugDomRootRenderer} from '@angular/core'; +import {Injectable, InjectionToken, Injector, NgModule, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactoryV2, RootRenderer, createPlatformFactory, isDevMode, platformCore, ɵALLOW_MULTIPLE_PLATFORMS as ALLOW_MULTIPLE_PLATFORMS, ɵDebugDomRootRenderer as DebugDomRootRenderer} from '@angular/core'; import {HttpModule} from '@angular/http'; import {BrowserModule, DOCUMENT, ɵSharedStylesHost as SharedStylesHost, ɵgetDOM as getDOM} from '@angular/platform-browser'; import {SERVER_HTTP_PROVIDERS} from './http'; @@ -39,10 +39,6 @@ export function _createConditionalRootRenderer(rootRenderer: any) { return isDevMode() ? new DebugDomRootRenderer(rootRenderer) : rootRenderer; } -export function _addStylesToRootComponentFactory(stylesHost: ServerStylesHost) { - const initializer = () => stylesHost.rootComponentIsReady(); - return initializer; -} export const SERVER_RENDER_PROVIDERS: Provider[] = [ ServerRootRenderer, @@ -51,12 +47,6 @@ export const SERVER_RENDER_PROVIDERS: Provider[] = [ {provide: RendererFactoryV2, useExisting: ServerRendererFactoryV2}, ServerStylesHost, {provide: SharedStylesHost, useExisting: ServerStylesHost}, - { - provide: APP_BOOTSTRAP_LISTENER, - useFactory: _addStylesToRootComponentFactory, - deps: [ServerStylesHost], - multi: true - }, ]; /** diff --git a/modules/@angular/platform-server/src/styles_host.ts b/modules/@angular/platform-server/src/styles_host.ts index d22c8cfdeb..d85e763e70 100644 --- a/modules/@angular/platform-server/src/styles_host.ts +++ b/modules/@angular/platform-server/src/styles_host.ts @@ -6,39 +6,31 @@ * found in the LICENSE file at https://angular.io/license */ -import {ApplicationRef, Inject, Injectable} from '@angular/core'; -import {DOCUMENT, ɵSharedStylesHost as SharedStylesHost, ɵgetDOM as getDOM} from '@angular/platform-browser'; +import {ApplicationRef, Inject, Injectable, Optional} from '@angular/core'; +import {DOCUMENT, ɵSharedStylesHost as SharedStylesHost, ɵTRANSITION_ID, ɵgetDOM as getDOM} from '@angular/platform-browser'; import {Parse5DomAdapter} from './parse5_adapter'; @Injectable() export class ServerStylesHost extends SharedStylesHost { - private root: any = null; - private buffer: string[] = []; + private head: any = null; - constructor(@Inject(DOCUMENT) private doc: any, private appRef: ApplicationRef) { super(); } + constructor( + @Inject(DOCUMENT) private doc: any, + @Optional() @Inject(ɵTRANSITION_ID) private transitionId: string) { + super(); + this.head = getDOM().getElementsByTagName(doc, 'head')[0]; + } private _addStyle(style: string): void { let adapter: Parse5DomAdapter = getDOM() as Parse5DomAdapter; const el = adapter.createElement('style'); adapter.setText(el, style); - adapter.appendChild(this.root, el); + if (!!this.transitionId) { + adapter.setAttribute(el, 'ng-transition', this.transitionId); + } + adapter.appendChild(this.head, el); } - onStylesAdded(additions: Set) { - if (!this.root) { - additions.forEach(style => this.buffer.push(style)); - } else { - additions.forEach(style => this._addStyle(style)); - } - } - - rootComponentIsReady(): void { - if (!!this.root) { - return; - } - this.root = this.appRef.components[0].location.nativeElement; - this.buffer.forEach(style => this._addStyle(style)); - this.buffer = null; - } + onStylesAdded(additions: Set) { additions.forEach(style => this._addStyle(style)); } } diff --git a/modules/@angular/platform-server/src/utils.ts b/modules/@angular/platform-server/src/utils.ts index 0e840f0449..d404253596 100644 --- a/modules/@angular/platform-server/src/utils.ts +++ b/modules/@angular/platform-server/src/utils.ts @@ -7,6 +7,7 @@ */ import {ApplicationRef, NgModuleFactory, NgModuleRef, PlatformRef, Provider, Type} from '@angular/core'; +import {ɵTRANSITION_ID} from '@angular/platform-browser'; import {filter} from 'rxjs/operator/filter'; import {first} from 'rxjs/operator/first'; import {toPromise} from 'rxjs/operator/toPromise'; @@ -36,6 +37,12 @@ function _getPlatform( function _render( platform: PlatformRef, moduleRefPromise: Promise>): Promise { return moduleRefPromise.then((moduleRef) => { + const transitionId = moduleRef.injector.get(ɵTRANSITION_ID, null); + if (!transitionId) { + throw new Error( + `renderModule[Factory]() requires the use of BrowserModule.withServerTransition() to ensure +the server-rendered app can be properly bootstrapped into a client app.`); + } const applicationRef: ApplicationRef = moduleRef.injector.get(ApplicationRef); return toPromise .call(first.call(filter.call(applicationRef.isStable, (isStable: boolean) => isStable))) diff --git a/modules/@angular/platform-server/test/integration_spec.ts b/modules/@angular/platform-server/test/integration_spec.ts index de85bda9f2..2c38796fd6 100644 --- a/modules/@angular/platform-server/test/integration_spec.ts +++ b/modules/@angular/platform-server/test/integration_spec.ts @@ -12,7 +12,7 @@ import {ApplicationRef, CompilerFactory, Component, NgModule, NgModuleRef, NgZon import {TestBed, async, inject} from '@angular/core/testing'; import {Http, HttpModule, Response, ResponseOptions, XHRBackend} from '@angular/http'; import {MockBackend, MockConnection} from '@angular/http/testing'; -import {DOCUMENT} from '@angular/platform-browser'; +import {BrowserModule, DOCUMENT} from '@angular/platform-browser'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {INITIAL_CONFIG, PlatformState, ServerModule, platformDynamicServer, renderModule, renderModuleFactory} from '@angular/platform-server'; import {Subscription} from 'rxjs/Subscription'; @@ -53,8 +53,11 @@ class MyAsyncServerApp { } } -@NgModule( - {declarations: [MyAsyncServerApp], imports: [ServerModule], bootstrap: [MyAsyncServerApp]}) +@NgModule({ + declarations: [MyAsyncServerApp], + imports: [BrowserModule.withServerTransition({appId: 'async-server'}), ServerModule], + bootstrap: [MyAsyncServerApp] +}) class AsyncServerModule { } @@ -62,7 +65,11 @@ class AsyncServerModule { class MyStylesApp { } -@NgModule({declarations: [MyStylesApp], imports: [ServerModule], bootstrap: [MyStylesApp]}) +@NgModule({ + declarations: [MyStylesApp], + imports: [BrowserModule.withServerTransition({appId: 'example-styles'}), ServerModule], + bootstrap: [MyStylesApp] +}) class ExampleStylesModule { } @@ -157,16 +164,18 @@ function declareTests({viewEngine}: {viewEngine: boolean}) { }); })); - it('adds styles to the root component', async(() => { - const platform = platformDynamicServer( - [{provide: INITIAL_CONFIG, useValue: {document: ''}}]); + it('adds styles with ng-transition attribute', async(() => { + const platform = platformDynamicServer([{ + provide: INITIAL_CONFIG, + useValue: {document: ''} + }]); platform.bootstrapModule(ExampleStylesModule).then(ref => { - const appRef: ApplicationRef = ref.injector.get(ApplicationRef); - const app = appRef.components[0].location.nativeElement; - expect(app.children.length).toBe(2); - const style = app.children[1]; - expect(style.type).toBe('style'); - expect(style.children[0].data).toContain('color: red'); + const doc = ref.injector.get(DOCUMENT); + const head = getDOM().getElementsByTagName(doc, 'head')[0]; + const styles: any[] = head.children as any; + expect(styles.length).toBe(1); + expect(getDOM().getText(styles[0])).toContain('color: red'); + expect(getDOM().getAttribute(styles[0], 'ng-transition')).toBe('example-styles'); }); })); diff --git a/tools/public_api_guard/platform-browser/typings/platform-browser.d.ts b/tools/public_api_guard/platform-browser/typings/platform-browser.d.ts index 680cdcbd6b..c5e3d2d87a 100644 --- a/tools/public_api_guard/platform-browser/typings/platform-browser.d.ts +++ b/tools/public_api_guard/platform-browser/typings/platform-browser.d.ts @@ -7,6 +7,9 @@ export declare abstract class AnimationDriver { /** @stable */ export declare class BrowserModule { constructor(parentModule: BrowserModule); + /** @experimental */ static withServerTransition(params: { + appId: string; + }): ModuleWithProviders; } /** @experimental */