feat(platform-server): add an API to transfer state from server (#19134)

TransferState provides a shared store that is transferred from the
server to client. To use it import BrowserTransferStateModule from the
client app module and ServerTransferStateModule from the server app
module and TransferState will be available as an Injectable object.

PR Close #19134
This commit is contained in:
Vikram Subramanian
2017-09-11 00:18:55 -07:00
committed by Igor Minar
parent f96142cd7c
commit cfd9ca0d6f
18 changed files with 571 additions and 2 deletions

View File

@ -0,0 +1,29 @@
/**
* @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 {browser, by, element} from 'protractor';
import {verifyNoBrowserErrors} from './util';
describe('TransferState', function() {
it('should transfer component state', function() {
// Load the page without waiting for Angular since it is not boostrapped automatically.
browser.driver.get(browser.baseUrl + 'transferstate');
// Test the contents from the server.
const serverDiv = browser.driver.findElement(by.css('div'));
expect(serverDiv.getText()).toEqual('5');
// Bootstrap the client side app and retest the contents
browser.executeScript('doBootstrap()');
expect(element(by.css('div')).getText()).toEqual('50');
// Make sure there were no client side errors.
verifyNoBrowserErrors();
});
});

View File

@ -15,6 +15,9 @@ import * as express from 'express';
import {HelloWorldServerModuleNgFactory} from './helloworld/app.server.ngfactory';
const helloworld = require('raw-loader!./helloworld/index.html');
import {TransferStateServerModuleNgFactory} from './transferstate/app.server.ngfactory';
const transferstate = require('raw-loader!./transferstate/index.html');
const app = express();
function render<T>(moduleFactory: NgModuleFactory<T>, html: string) {
@ -36,5 +39,6 @@ app.get('/favicon.ico', (req, res) => { res.send(''); });
//-----------ADD YOUR SERVER SIDE RENDERED APP HERE ----------------------
app.get('/helloworld', render(HelloWorldServerModuleNgFactory, helloworld));
app.get('/transferstate', render(TransferStateServerModuleNgFactory, transferstate));
app.listen(9876, function() { console.log('Server listening on port 9876!'); });

View File

@ -0,0 +1,20 @@
/**
* @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 {NgModule} from '@angular/core';
import {ServerModule, ServerTransferStateModule} from '@angular/platform-server';
import {TransferStateModule} from './app';
import {TransferStateComponent} from './transfer-state.component';
@NgModule({
bootstrap: [TransferStateComponent],
imports: [TransferStateModule, ServerModule, ServerTransferStateModule],
})
export class TransferStateServerModule {
}

View File

@ -0,0 +1,23 @@
/**
* @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 {NgModule} from '@angular/core';
import {BrowserModule, BrowserTransferStateModule} from '@angular/platform-browser';
import {TransferStateComponent} from './transfer-state.component';
@NgModule({
declarations: [TransferStateComponent],
bootstrap: [TransferStateComponent],
imports: [
BrowserModule.withServerTransition({appId: 'ts'}),
BrowserTransferStateModule,
],
})
export class TransferStateModule {
}

View File

@ -0,0 +1,17 @@
/**
* @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 'zone.js/dist/zone.js';
import {enableProdMode} from '@angular/core';
import {platformBrowser} from '@angular/platform-browser';
import {TransferStateModuleNgFactory} from './app.ngfactory';
window['doBootstrap'] = function() {
platformBrowser().bootstrapModuleFactory(TransferStateModuleNgFactory);
};

View File

@ -0,0 +1,10 @@
<html>
<head>
<meta charset="UTF-8">
<title>Hello World</title>
<script src="built/transferstate-bundle.js"></script>
</head>
<body>
<transfer-state-app></transfer-state-app>
</body>
</html>

View File

@ -0,0 +1,36 @@
/**
* @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 {isPlatformServer} from '@angular/common';
import {Component, Inject, PLATFORM_ID} from '@angular/core';
import {StateKey, TransferState, makeStateKey} from '@angular/platform-browser';
const COUNTER_KEY = makeStateKey<number>('counter');
@Component({
selector: 'transfer-state-app',
template: `
<div>{{counter}}</div>
`,
})
export class TransferStateComponent {
counter = 0;
constructor(@Inject(PLATFORM_ID) private platformId: {}, private transferState: TransferState) {}
ngOnInit() {
if (isPlatformServer(this.platformId)) {
// Set it to 5 in the server.
this.counter = 5;
this.transferState.set(COUNTER_KEY, 50);
} else {
// Get the transferred counter state in the client(should be 50 and not 0).
this.counter = this.transferState.get(COUNTER_KEY, 0);
}
}
}

View File

@ -11,6 +11,7 @@ const path = require('path');
module.exports = {
entry: {
helloworld: './built/src/helloworld/client.js',
transferstate: './built/src/transferstate/client.js',
},
output: {path: path.join(__dirname, 'built'), filename: '[name]-bundle.js'},
module: {loaders: [{test: /\.js$/, loader: 'babel-loader?presets[]=es2015'}]},

View File

@ -9,6 +9,7 @@
export {PlatformState} from './platform_state';
export {ServerModule, platformDynamicServer, platformServer} from './server';
export {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
export {ServerTransferStateModule} from './transfer_state';
export {renderModule, renderModuleFactory} from './utils';
export * from './private_export';

View File

@ -0,0 +1,42 @@
/**
* @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_ID, NgModule} from '@angular/core';
import {DOCUMENT, TransferState, ɵescapeHtml as escapeHtml} from '@angular/platform-browser';
import {BEFORE_APP_SERIALIZED} from './tokens';
export function serializeTransferStateFactory(
doc: Document, appId: string, transferStore: TransferState) {
return () => {
const script = doc.createElement('script');
script.id = appId + '-state';
script.setAttribute('type', 'application/json');
script.textContent = escapeHtml(transferStore.toJson());
doc.body.appendChild(script);
};
}
/**
* NgModule to install on the server side while using the `TransferState` to transfer state from
* server to client.
*
* @experimental
*/
@NgModule({
providers: [
TransferState, {
provide: BEFORE_APP_SERIALIZED,
useFactory: serializeTransferStateFactory,
deps: [DOCUMENT, APP_ID, TransferState],
multi: true,
}
]
})
export class ServerTransferStateModule {
}

View File

@ -14,9 +14,9 @@ import {ApplicationRef, CompilerFactory, Component, HostListener, Input, NgModul
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, Title} from '@angular/platform-browser';
import {BrowserModule, DOCUMENT, StateKey, Title, TransferState, makeStateKey} from '@angular/platform-browser';
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformState, ServerModule, platformDynamicServer, renderModule, renderModuleFactory} from '@angular/platform-server';
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformState, ServerModule, ServerTransferStateModule, platformDynamicServer, renderModule, renderModuleFactory} from '@angular/platform-server';
import {Subscription} from 'rxjs/Subscription';
import {filter} from 'rxjs/operator/filter';
import {first} from 'rxjs/operator/first';
@ -257,6 +257,47 @@ class MyInputComponent {
class NameModule {
}
const TEST_KEY = makeStateKey<number>('test');
const STRING_KEY = makeStateKey<string>('testString');
@Component({selector: 'app', template: 'Works!'})
class TransferComponent {
constructor(private transferStore: TransferState) {}
ngOnInit() { this.transferStore.set(TEST_KEY, 10); }
}
@Component({selector: 'esc-app', template: 'Works!'})
class EscapedComponent {
constructor(private transferStore: TransferState) {}
ngOnInit() {
this.transferStore.set(STRING_KEY, '</script><script>alert(\'Hello&\' + "World");');
}
}
@NgModule({
bootstrap: [TransferComponent],
declarations: [TransferComponent],
imports: [
BrowserModule.withServerTransition({appId: 'transfer'}),
ServerModule,
ServerTransferStateModule,
]
})
class TransferStoreModule {
}
@NgModule({
bootstrap: [EscapedComponent],
declarations: [EscapedComponent],
imports: [
BrowserModule.withServerTransition({appId: 'transfer'}),
ServerModule,
ServerTransferStateModule,
]
})
class EscapedTransferStoreModule {
}
export function main() {
if (getDOM().supportsDOMEvents()) return; // NODE only
@ -673,5 +714,46 @@ export function main() {
});
}));
});
describe('ServerTransferStoreModule', () => {
let called = false;
const defaultExpectedOutput =
'<html><head></head><body><app ng-version="0.0.0-PLACEHOLDER">Works!</app><script id="transfer-state" type="application/json">{&q;test&q;:10}</script></body></html>';
beforeEach(() => { called = false; });
afterEach(() => { expect(called).toBe(true); });
it('adds transfer script tag when using renderModule', async(() => {
renderModule(TransferStoreModule, {document: '<app></app>'}).then(output => {
expect(output).toBe(defaultExpectedOutput);
called = true;
});
}));
it('adds transfer script tag when using renderModuleFactory',
async(inject([PlatformRef], (defaultPlatform: PlatformRef) => {
const compilerFactory: CompilerFactory =
defaultPlatform.injector.get(CompilerFactory, null);
const moduleFactory =
compilerFactory.createCompiler().compileModuleSync(TransferStoreModule);
renderModuleFactory(moduleFactory, {document: '<app></app>'}).then(output => {
expect(output).toBe(defaultExpectedOutput);
called = true;
});
})));
it('cannot break out of <script> tag in serialized output', async(() => {
renderModule(EscapedTransferStoreModule, {
document: '<esc-app></esc-app>'
}).then(output => {
expect(output).toBe(
'<html><head></head><body><esc-app ng-version="0.0.0-PLACEHOLDER">Works!</esc-app>' +
'<script id="transfer-state" type="application/json">' +
'{&q;testString&q;:&q;&l;/script&g;&l;script&g;' +
'alert(&s;Hello&a;&s; + \\&q;World\\&q;);&q;}</script></body></html>');
called = true;
});
}));
});
});
}