fix: app ids for better <style> management for ssr (#14632)

Currently styles are rendered to the root component element, which ensures they're cleaned up automatically
when the client application is bootstrapped. This is less than ideal as progressive rendering can cause HTML
to be rendered before the CSS is loaded, causing flicker.

This change returns to rendering <style> elements in the <head>, and introduces a mechanism for removing
them on client bootstrap. This relies on associating the server and client bootstrap. Another way to think
of this is that the client, when bootstrapping an app, needs to know whether to expect a server rendered
application exists on the page, and to identify the <style> elements that are part of that app in order
to remove them.

This is accomplished by providing a string TRANSITION_ID on both server and client. For most applications,
this will be achieved by writing a client app module that imports BrowserModule.withServerTransition({appId: <id>}).
The server app module will import this client app module and therefore inherit the provider for
TRANSITION_ID. renderModule[Factory] on the server will validate that a TRANSITION_ID has been provided.
This commit is contained in:
Alex Rickabaugh 2017-02-22 16:06:21 -08:00 committed by Igor Minar
parent 830393d234
commit 88bc143431
9 changed files with 144 additions and 47 deletions

View File

@ -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,
],
};
}
}

View File

@ -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
},
];

View File

@ -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';

View File

@ -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<ComponentRef<any>> = bootstrap(HelloRootCmp, testProviders);

View File

@ -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
},
];
/**

View File

@ -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<string>) {
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<string>) { additions.forEach(style => this._addStyle(style)); }
}

View File

@ -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<T>(
platform: PlatformRef, moduleRefPromise: Promise<NgModuleRef<T>>): Promise<string> {
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)))

View File

@ -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: '<app></app>'}}]);
it('adds styles with ng-transition attribute', async(() => {
const platform = platformDynamicServer([{
provide: INITIAL_CONFIG,
useValue: {document: '<html><head></head><body><app></app></body></html>'}
}]);
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');
});
}));

View File

@ -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 */