
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.
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
/**
|
|
* @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 {PlatformLocation} from '@angular/common';
|
|
import {USE_VIEW_ENGINE} from '@angular/compiler/src/config';
|
|
import {ApplicationRef, CompilerFactory, Component, NgModule, NgModuleRef, NgZone, PlatformRef, destroyPlatform, getPlatform} from '@angular/core';
|
|
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 {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';
|
|
import {filter} from 'rxjs/operator/filter';
|
|
import {first} from 'rxjs/operator/first';
|
|
import {toPromise} from 'rxjs/operator/toPromise';
|
|
|
|
@Component({selector: 'app', template: `Works!`})
|
|
class MyServerApp {
|
|
}
|
|
|
|
@NgModule({
|
|
bootstrap: [MyServerApp],
|
|
declarations: [MyServerApp],
|
|
imports: [ServerModule],
|
|
providers: [
|
|
MockBackend,
|
|
{provide: XHRBackend, useExisting: MockBackend},
|
|
]
|
|
})
|
|
class ExampleModule {
|
|
}
|
|
|
|
@Component({selector: 'app', template: `Works too!`})
|
|
class MyServerApp2 {
|
|
}
|
|
|
|
@NgModule({declarations: [MyServerApp2], imports: [ServerModule], bootstrap: [MyServerApp2]})
|
|
class ExampleModule2 {
|
|
}
|
|
|
|
@Component({selector: 'app', template: '{{text}}'})
|
|
class MyAsyncServerApp {
|
|
text = '';
|
|
|
|
ngOnInit() {
|
|
Promise.resolve(null).then(() => setTimeout(() => { this.text = 'Works!'; }, 10));
|
|
}
|
|
}
|
|
|
|
@NgModule({
|
|
declarations: [MyAsyncServerApp],
|
|
imports: [BrowserModule.withServerTransition({appId: 'async-server'}), ServerModule],
|
|
bootstrap: [MyAsyncServerApp]
|
|
})
|
|
class AsyncServerModule {
|
|
}
|
|
|
|
@Component({selector: 'app', template: `Works!`, styles: [':host { color: red; }']})
|
|
class MyStylesApp {
|
|
}
|
|
|
|
@NgModule({
|
|
declarations: [MyStylesApp],
|
|
imports: [BrowserModule.withServerTransition({appId: 'example-styles'}), ServerModule],
|
|
bootstrap: [MyStylesApp]
|
|
})
|
|
class ExampleStylesModule {
|
|
}
|
|
|
|
@NgModule({
|
|
bootstrap: [MyServerApp],
|
|
declarations: [MyServerApp],
|
|
imports: [HttpModule, ServerModule],
|
|
providers: [
|
|
MockBackend,
|
|
{provide: XHRBackend, useExisting: MockBackend},
|
|
]
|
|
})
|
|
export class HttpBeforeExampleModule {
|
|
}
|
|
|
|
@NgModule({
|
|
bootstrap: [MyServerApp],
|
|
declarations: [MyServerApp],
|
|
imports: [ServerModule, HttpModule],
|
|
providers: [
|
|
MockBackend,
|
|
{provide: XHRBackend, useExisting: MockBackend},
|
|
]
|
|
})
|
|
export class HttpAfterExampleModule {
|
|
}
|
|
|
|
@Component({selector: 'app', template: `<img [src]="'link'">`})
|
|
class ImageApp {
|
|
}
|
|
|
|
@NgModule({declarations: [ImageApp], imports: [ServerModule], bootstrap: [ImageApp]})
|
|
class ImageExampleModule {
|
|
}
|
|
|
|
export function main() {
|
|
describe('regular', () => { declareTests({viewEngine: false}); });
|
|
|
|
describe('view engine', () => {
|
|
beforeEach(() => {
|
|
TestBed.configureCompiler({
|
|
useJit: true,
|
|
providers: [{
|
|
provide: USE_VIEW_ENGINE,
|
|
useValue: true,
|
|
}],
|
|
});
|
|
});
|
|
|
|
declareTests({viewEngine: true});
|
|
});
|
|
}
|
|
|
|
|
|
function declareTests({viewEngine}: {viewEngine: boolean}) {
|
|
if (getDOM().supportsDOMEvents()) return; // NODE only
|
|
|
|
describe('platform-server integration', () => {
|
|
beforeEach(() => {
|
|
if (getPlatform()) destroyPlatform();
|
|
});
|
|
|
|
it('should bootstrap', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
|
|
platform.bootstrapModule(ExampleModule).then((moduleRef) => {
|
|
const doc = moduleRef.injector.get(DOCUMENT);
|
|
expect(getDOM().getText(doc)).toEqual('Works!');
|
|
platform.destroy();
|
|
});
|
|
}));
|
|
|
|
it('should allow multiple platform instances', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
|
|
const platform2 = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
|
|
|
|
platform.bootstrapModule(ExampleModule).then((moduleRef) => {
|
|
const doc = moduleRef.injector.get(DOCUMENT);
|
|
expect(getDOM().getText(doc)).toEqual('Works!');
|
|
platform.destroy();
|
|
});
|
|
|
|
platform2.bootstrapModule(ExampleModule2).then((moduleRef) => {
|
|
const doc = moduleRef.injector.get(DOCUMENT);
|
|
expect(getDOM().getText(doc)).toEqual('Works too!');
|
|
platform2.destroy();
|
|
});
|
|
}));
|
|
|
|
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 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');
|
|
});
|
|
}));
|
|
|
|
it('copies known properties to attributes', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ImageExampleModule).then(ref => {
|
|
const appRef: ApplicationRef = ref.injector.get(ApplicationRef);
|
|
const app = appRef.components[0].location.nativeElement;
|
|
const img = getDOM().getElementsByTagName(app, 'img')[0] as any;
|
|
expect(img.attribs['src']).toEqual('link');
|
|
});
|
|
}));
|
|
|
|
describe('PlatformLocation', () => {
|
|
it('is injectable', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ExampleModule).then(appRef => {
|
|
const location: PlatformLocation = appRef.injector.get(PlatformLocation);
|
|
expect(location.pathname).toBe('/');
|
|
platform.destroy();
|
|
});
|
|
}));
|
|
it('is configurable via INITIAL_CONFIG', () => {
|
|
platformDynamicServer([{
|
|
provide: INITIAL_CONFIG,
|
|
useValue: {document: '<app></app>', url: 'http://test.com/deep/path?query#hash'}
|
|
}])
|
|
.bootstrapModule(ExampleModule)
|
|
.then(appRef => {
|
|
const location: PlatformLocation = appRef.injector.get(PlatformLocation);
|
|
expect(location.pathname).toBe('/deep/path');
|
|
expect(location.search).toBe('?query');
|
|
expect(location.hash).toBe('#hash');
|
|
});
|
|
});
|
|
it('handles empty search and hash portions of the url', () => {
|
|
platformDynamicServer([{
|
|
provide: INITIAL_CONFIG,
|
|
useValue: {document: '<app></app>', url: 'http://test.com/deep/path'}
|
|
}])
|
|
.bootstrapModule(ExampleModule)
|
|
.then(appRef => {
|
|
const location: PlatformLocation = appRef.injector.get(PlatformLocation);
|
|
expect(location.pathname).toBe('/deep/path');
|
|
expect(location.search).toBe('');
|
|
expect(location.hash).toBe('');
|
|
});
|
|
});
|
|
it('pushState causes the URL to update', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ExampleModule).then(appRef => {
|
|
const location: PlatformLocation = appRef.injector.get(PlatformLocation);
|
|
location.pushState(null, 'Test', '/foo#bar');
|
|
expect(location.pathname).toBe('/foo');
|
|
expect(location.hash).toBe('#bar');
|
|
platform.destroy();
|
|
});
|
|
}));
|
|
it('allows subscription to the hash state', done => {
|
|
const platform =
|
|
platformDynamicServer([{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ExampleModule).then(appRef => {
|
|
const location: PlatformLocation = appRef.injector.get(PlatformLocation);
|
|
expect(location.pathname).toBe('/');
|
|
location.onHashChange((e: any) => {
|
|
expect(e.type).toBe('hashchange');
|
|
expect(e.oldUrl).toBe('/');
|
|
expect(e.newUrl).toBe('/foo#bar');
|
|
platform.destroy();
|
|
done();
|
|
});
|
|
location.pushState(null, 'Test', '/foo#bar');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('render', () => {
|
|
let doc: string;
|
|
let called: boolean;
|
|
let expectedOutput =
|
|
'<html><head></head><body><app ng-version="0.0.0-PLACEHOLDER">Works!</app></body></html>';
|
|
|
|
beforeEach(() => {
|
|
// PlatformConfig takes in a parsed document so that it can be cached across requests.
|
|
doc = '<html><head></head><body><app></app></body></html>';
|
|
called = false;
|
|
});
|
|
afterEach(() => { expect(called).toBe(true); });
|
|
|
|
it('using long from should work', async(() => {
|
|
const platform =
|
|
platformDynamicServer([{provide: INITIAL_CONFIG, useValue: {document: doc}}]);
|
|
|
|
platform.bootstrapModule(AsyncServerModule)
|
|
.then((moduleRef) => {
|
|
const applicationRef: ApplicationRef = moduleRef.injector.get(ApplicationRef);
|
|
return toPromise.call(first.call(
|
|
filter.call(applicationRef.isStable, (isStable: boolean) => isStable)));
|
|
})
|
|
.then((b) => {
|
|
expect(platform.injector.get(PlatformState).renderToString()).toBe(expectedOutput);
|
|
platform.destroy();
|
|
called = true;
|
|
});
|
|
}));
|
|
|
|
it('using renderModule should work', async(() => {
|
|
renderModule(AsyncServerModule, {document: doc}).then(output => {
|
|
expect(output).toBe(expectedOutput);
|
|
called = true;
|
|
});
|
|
}));
|
|
|
|
it('using renderModuleFactory should work',
|
|
async(inject([PlatformRef], (defaultPlatform: PlatformRef) => {
|
|
const compilerFactory: CompilerFactory =
|
|
defaultPlatform.injector.get(CompilerFactory, null);
|
|
const moduleFactory =
|
|
compilerFactory.createCompiler().compileModuleSync(AsyncServerModule);
|
|
renderModuleFactory(moduleFactory, {document: doc}).then(output => {
|
|
expect(output).toBe(expectedOutput);
|
|
called = true;
|
|
});
|
|
})));
|
|
});
|
|
|
|
describe('http', () => {
|
|
it('can inject Http', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ExampleModule).then(ref => {
|
|
expect(ref.injector.get(Http) instanceof Http).toBeTruthy();
|
|
});
|
|
}));
|
|
it('can make Http requests', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ExampleModule).then(ref => {
|
|
const mock = ref.injector.get(MockBackend);
|
|
const http = ref.injector.get(Http);
|
|
ref.injector.get(NgZone).run(() => {
|
|
NgZone.assertInAngularZone();
|
|
mock.connections.subscribe((mc: MockConnection) => {
|
|
NgZone.assertInAngularZone();
|
|
expect(mc.request.url).toBe('/testing');
|
|
mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200})));
|
|
});
|
|
http.get('/testing').subscribe(resp => {
|
|
NgZone.assertInAngularZone();
|
|
expect(resp.text()).toBe('success!');
|
|
});
|
|
});
|
|
});
|
|
}));
|
|
it('requests are macrotasks', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(ExampleModule).then(ref => {
|
|
const mock = ref.injector.get(MockBackend);
|
|
const http = ref.injector.get(Http);
|
|
expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeFalsy();
|
|
ref.injector.get(NgZone).run(() => {
|
|
NgZone.assertInAngularZone();
|
|
mock.connections.subscribe((mc: MockConnection) => {
|
|
expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeTruthy();
|
|
mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200})));
|
|
});
|
|
http.get('/testing').subscribe(resp => { expect(resp.text()).toBe('success!'); });
|
|
});
|
|
});
|
|
}));
|
|
it('works when HttpModule is included before ServerModule', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(HttpBeforeExampleModule).then(ref => {
|
|
const mock = ref.injector.get(MockBackend);
|
|
const http = ref.injector.get(Http);
|
|
expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeFalsy();
|
|
ref.injector.get(NgZone).run(() => {
|
|
NgZone.assertInAngularZone();
|
|
mock.connections.subscribe((mc: MockConnection) => {
|
|
expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeTruthy();
|
|
mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200})));
|
|
});
|
|
http.get('/testing').subscribe(resp => { expect(resp.text()).toBe('success!'); });
|
|
});
|
|
});
|
|
}));
|
|
it('works when HttpModule is included after ServerModule', async(() => {
|
|
const platform = platformDynamicServer(
|
|
[{provide: INITIAL_CONFIG, useValue: {document: '<app></app>'}}]);
|
|
platform.bootstrapModule(HttpAfterExampleModule).then(ref => {
|
|
const mock = ref.injector.get(MockBackend);
|
|
const http = ref.injector.get(Http);
|
|
expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeFalsy();
|
|
ref.injector.get(NgZone).run(() => {
|
|
NgZone.assertInAngularZone();
|
|
mock.connections.subscribe((mc: MockConnection) => {
|
|
expect(ref.injector.get(NgZone).hasPendingMacrotasks).toBeTruthy();
|
|
mc.mockRespond(new Response(new ResponseOptions({body: 'success!', status: 200})));
|
|
});
|
|
http.get('/testing').subscribe(resp => { expect(resp.text()).toBe('success!'); });
|
|
});
|
|
});
|
|
}));
|
|
});
|
|
});
|
|
}
|