From 9fb883db871d8979cda5878ca7f9b9eca8e95bfa Mon Sep 17 00:00:00 2001 From: Josh Perez Date: Sat, 30 May 2015 19:38:34 -0700 Subject: [PATCH 1/2] The holy-grail of server rendering --- .eslintrc | 1 + dist/alt-with-addons.js | 20 ++-- dist/alt.js | 20 ++-- src/alt/store/StoreMixin.js | 22 ++-- src/utils/AltIso.js | 24 +++++ src/utils/Render.js | 122 ++++++++++++++++++++++ test/alt-iso-browser-test.js | 147 ++++++++++++++++++++++++++ test/alt-iso-test.js | 194 +++++++++++++++++++++++++++++++++++ test/async-test.js | 53 +++++++++- 9 files changed, 576 insertions(+), 27 deletions(-) create mode 100644 src/utils/AltIso.js create mode 100644 src/utils/Render.js create mode 100644 test/alt-iso-browser-test.js create mode 100644 test/alt-iso-test.js diff --git a/.eslintrc b/.eslintrc index de698e0a..d24a669e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,7 @@ { "env": { "node": true, + "browser": true, "es6": true }, "ecmaFeatures": { diff --git a/dist/alt-with-addons.js b/dist/alt-with-addons.js index 48930912..5510a5d2 100644 --- a/dist/alt-with-addons.js +++ b/dist/alt-with-addons.js @@ -1568,18 +1568,24 @@ var StoreMixin = { return x; }; + var makeActionHandler = function makeActionHandler(action) { + return function (x) { + var fire = function fire() { + loadCounter -= 1; + action(intercept(x, action, args)); + }; + return typeof window === 'undefined' ? function () { + return fire(); + } : fire(); + }; + }; + // if we don't have it in cache then fetch it if (shouldFetch) { loadCounter += 1; /* istanbul ignore else */ if (spec.loading) spec.loading(intercept(null, spec.loading, args)); - spec.remote.apply(spec, [state].concat(args)).then(function (v) { - loadCounter -= 1; - spec.success(intercept(v, spec.success, args)); - })['catch'](function (v) { - loadCounter -= 1; - spec.error(intercept(v, spec.error, args)); - }); + return spec.remote.apply(spec, [state].concat(args)).then(makeActionHandler(spec.success))['catch'](makeActionHandler(spec.error)); } else { // otherwise emit the change now _this.emitChange(); diff --git a/dist/alt.js b/dist/alt.js index 4e010ec8..5a543585 100644 --- a/dist/alt.js +++ b/dist/alt.js @@ -986,18 +986,24 @@ var StoreMixin = { return x; }; + var makeActionHandler = function makeActionHandler(action) { + return function (x) { + var fire = function fire() { + loadCounter -= 1; + action(intercept(x, action, args)); + }; + return typeof window === 'undefined' ? function () { + return fire(); + } : fire(); + }; + }; + // if we don't have it in cache then fetch it if (shouldFetch) { loadCounter += 1; /* istanbul ignore else */ if (spec.loading) spec.loading(intercept(null, spec.loading, args)); - spec.remote.apply(spec, [state].concat(args)).then(function (v) { - loadCounter -= 1; - spec.success(intercept(v, spec.success, args)); - })['catch'](function (v) { - loadCounter -= 1; - spec.error(intercept(v, spec.error, args)); - }); + return spec.remote.apply(spec, [state].concat(args)).then(makeActionHandler(spec.success))['catch'](makeActionHandler(spec.error)); } else { // otherwise emit the change now _this.emitChange(); diff --git a/src/alt/store/StoreMixin.js b/src/alt/store/StoreMixin.js index 63e58677..4a438f2f 100644 --- a/src/alt/store/StoreMixin.js +++ b/src/alt/store/StoreMixin.js @@ -51,20 +51,24 @@ const StoreMixin = { : value == null const intercept = spec.interceptResponse || (x => x) + const makeActionHandler = (action) => { + return (x) => { + const fire = () => { + loadCounter -= 1 + action(intercept(x, action, args)) + } + return typeof window === 'undefined' ? (() => fire()) : fire() + } + } + // if we don't have it in cache then fetch it if (shouldFetch) { loadCounter += 1 /* istanbul ignore else */ if (spec.loading) spec.loading(intercept(null, spec.loading, args)) - spec.remote(state, ...args) - .then((v) => { - loadCounter -= 1 - spec.success(intercept(v, spec.success, args)) - }) - .catch((v) => { - loadCounter -= 1 - spec.error(intercept(v, spec.error, args)) - }) + return spec.remote(state, ...args) + .then(makeActionHandler(spec.success)) + .catch(makeActionHandler(spec.error)) } else { // otherwise emit the change now this.emitChange() diff --git a/src/utils/AltIso.js b/src/utils/AltIso.js new file mode 100644 index 00000000..2a40d493 --- /dev/null +++ b/src/utils/AltIso.js @@ -0,0 +1,24 @@ +import Iso from 'iso' +import * as Render from './Render' + +export default { + define: Render.withData, + + render(alt, Component, props) { + // recycle state + alt.recycle() + + if (typeof window === 'undefined') { + return Render.toString(Component, props).then((markup) => { + return Iso.render(markup, alt.takeSnapshot()) + }) + } else { + return Promise.resolve( + Iso.bootstrap((state, _, node) => { + alt.bootstrap(state) + Render.toDOM(Component, props, node) + }) + ) + } + } +} diff --git a/src/utils/Render.js b/src/utils/Render.js new file mode 100644 index 00000000..2878d2a9 --- /dev/null +++ b/src/utils/Render.js @@ -0,0 +1,122 @@ +import React from 'react' + +export function withData(fetch, MaybeComponent) { + function bind(Component) { + return React.createClass({ + contextTypes: { + buffer: React.PropTypes.object.isRequired + }, + + childContextTypes: { + buffer: React.PropTypes.object.isRequired + }, + + getChildContext() { + return { buffer: this.context.buffer } + }, + + componentWillMount() { + if (!this.context.buffer.locked) { + this.context.buffer.push( + fetch(this.props) + ) + } + }, + + render() { + return React.createElement(Component, this.props) + } + }) + } + + // works as a decorator or as a function + return MaybeComponent ? bind(MaybeComponent) : Component => bind(Component) +} + +function usingDispatchBuffer(buffer, Component) { + return React.createClass({ + childContextTypes: { + buffer: React.PropTypes.object.isRequired + }, + + getChildContext() { + return { buffer } + }, + + render() { + return React.createElement(Component, this.props) + } + }) +} + +class DispatchBuffer { + constructor(renderStrategy) { + this.promisesBuffer = [] + this.locked = false + this.renderStrategy = renderStrategy + } + + push(v) { + this.promisesBuffer.push(v) + } + + fill(Element) { + return this.renderStrategy(Element) + } + + clear() { + this.promisesBuffer = [] + } + + flush(Element) { + return Promise.all(this.promisesBuffer).then((data) => { + // fire off all the actions synchronously + data.forEach((f) => { + if (Array.isArray(f)) { + f.forEach(x => x()) + } else { + f() + } + }) + this.locked = true + + return this.renderStrategy(Element) + }).catch(() => { + // if there's an error still render the markup with what we've got. + return this.renderStrategy(Element) + }) + } +} + + +function renderWithStrategy(strategy) { + return (Component, props) => { + // create a buffer and use context to pass it through to the components + const buffer = new DispatchBuffer((Node) => { + return React[strategy](Node) + }) + const Container = usingDispatchBuffer(buffer, Component) + + // cache the element + const Element = React.createElement(Container, props) + + // render so we kick things off and get the props + buffer.fill(Element) + + // flush out the results in the buffer synchronously setting the store + // state and returning the markup + return buffer.flush(Element) + } +} + +export function toDOM(Component, props, documentNode) { + const buffer = new DispatchBuffer() + buffer.locked = true + const Node = usingDispatchBuffer(buffer, Component) + const Element = React.createElement(Node, props) + buffer.clear() + return React.render(Element, documentNode) +} + +export const toStaticMarkup = renderWithStrategy('renderToStaticMarkup') +export const toString = renderWithStrategy('renderToString') diff --git a/test/alt-iso-browser-test.js b/test/alt-iso-browser-test.js new file mode 100644 index 00000000..2d64b63a --- /dev/null +++ b/test/alt-iso-browser-test.js @@ -0,0 +1,147 @@ +import { jsdom } from 'jsdom' +import React from 'react' +import Alt from '../' +import AltContainer from '../AltContainer' +import AltIso from '../utils/AltIso' +import { assert } from 'chai' + +const alt = new Alt() + +const UserActions = alt.generateActions('receivedUser', 'failed') + +const UserSource = { + fetchUser() { + return { + remote(state, id, name) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve({ id, name }), 10) + }) + }, + + success: UserActions.receivedUser, + error: UserActions.failed + } + } +} + +class UserStore { + static displayName = 'UserStore' + + constructor() { + this.user = null + + this.exportAsync(UserSource) + this.bindActions(UserActions) + } + + receivedUser(user) { + this.user = user + } + + failed(e) { + console.error('Failure', e) + } +} + +const userStore = alt.createStore(UserStore) + +const NumberActions = alt.generateActions('receivedNumber', 'failed') + +const NumberSource = { + fetchNumber() { + return { + remote(state, id) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(id), 5) + }) + }, + + success: NumberActions.receivedNumber, + error: NumberActions.failed + } + } +} + +class NumberStore { + static displayName = 'NumberStore' + + constructor() { + this.n = [] + this.exportAsync(NumberSource) + this.bindActions(NumberActions) + } + + receivedNumber(n) { + this.n = n + } + + failed(e) { + console.error(e) + } +} + +const numberStore = alt.createStore(NumberStore) + +@AltIso.define((props) => { + return Promise.all([ + userStore.fetchUser(props.id, props.name), + numberStore.fetchNumber(props.id) + ]) +}) +class User extends React.Component { + render() { + return ( +
+ { + return ( +
+

{props.user ? props.user.name : ''}

+ {props.user ? props.user.id : 0} +
+ ) + }} + /> + { + return {props.n} + }} + /> +
+ ) + } +} + +class App extends React.Component { + render() { + return + } +} + +export default { + 'AltIso browser': { + 'browser requests'(done) { + AltIso.render(alt, App, { id: 0, name: 'Z' }).then((markup) => { + global.document = jsdom( + `${markup}` + ) + global.window = global.document.parentWindow + global.navigator = global.window.navigator + }).then(() => { + return AltIso.render(alt, App, { id: 0, name: 'Z' }) + }).then((data) => { + assert(alt.stores.UserStore.getState().user.id === 0) + assert(alt.stores.UserStore.getState().user.name === 'Z') + assert.isUndefined(data) + + delete global.document + delete global.window + delete global.navigator + + done() + }) + }, + }, +} diff --git a/test/alt-iso-test.js b/test/alt-iso-test.js new file mode 100644 index 00000000..0765a6df --- /dev/null +++ b/test/alt-iso-test.js @@ -0,0 +1,194 @@ +import React from 'react' +import Alt from '../' +import AltContainer from '../AltContainer' +import AltIso from '../utils/AltIso' +import { assert } from 'chai' + +const alt = new Alt() + +const UserActions = alt.generateActions('receivedUser', 'failed') + +const UserSource = { + fetchUser() { + return { + remote(state, id, name) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve({ id, name }), 10) + }) + }, + + success: UserActions.receivedUser, + error: UserActions.failed + } + } +} + +class UserStore { + static displayName = 'UserStore' + + constructor() { + this.user = null + + this.exportAsync(UserSource) + this.bindActions(UserActions) + } + + receivedUser(user) { + this.user = user + } + + failed(e) { + console.error('Failure', e) + } +} + +const userStore = alt.createStore(UserStore) + +const NumberActions = alt.generateActions('receivedNumber', 'failed') + +const NumberSource = { + fetchNumber() { + return { + remote(state, id) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(id), 5) + }) + }, + + success: NumberActions.receivedNumber, + error: NumberActions.failed + } + } +} + +class NumberStore { + static displayName = 'NumberStore' + + constructor() { + this.n = [] + this.exportAsync(NumberSource) + this.bindActions(NumberActions) + } + + receivedNumber(n) { + this.n = n + } + + failed(e) { + console.error(e) + } +} + +const numberStore = alt.createStore(NumberStore) + +@AltIso.define((props) => { + return Promise.all([ + userStore.fetchUser(props.id, props.name), + numberStore.fetchNumber(props.id) + ]) +}) +class User extends React.Component { + render() { + return ( +
+ { + return ( +
+

{props.user ? props.user.name : ''}

+ {props.user ? props.user.id : 0} +
+ ) + }} + /> + { + return {props.n} + }} + /> +
+ ) + } +} + +class App extends React.Component { + render() { + return + } +} + +export default { + 'AltIso': { + 'concurrent server requests are resolved properly'(done) { + const promises = [] + function test(Component, props) { + promises.push(AltIso.render(alt, Component, props)) + } + + setTimeout(() => test(App, { id: 111111, name: 'AAAAAA' }), 10) + setTimeout(() => test(App, { id: 333333, name: 'CCCCCC' }), 20) + setTimeout(() => test(App, { id: 222222, name: 'BBBBBB' }), 10) + setTimeout(() => test(App, { id: 444444, name: 'DDDDDD' }), 20) + + setTimeout(() => { + Promise.all(promises).then((values) => { + assert.match(values[0], /AAAAAA/) + assert.match(values[0], /111111/) + + assert.match(values[1], /BBBBBB/) + assert.match(values[1], /222222/) + + assert.match(values[2], /CCCCCC/) + assert.match(values[2], /333333/) + + assert.match(values[3], /DDDDDD/) + assert.match(values[3], /444444/) + + done() + }) + }, 50) + }, + + 'not as a decorator'() { + const User = AltIso.define(() => { }, class extends React.Component { }) + assert.isFunction(User) + }, + + 'single fetch call, single request'(done) { + const User = AltIso.define((props) => { + return userStore.fetchUser(props.id, props.name) + }, class extends React.Component { + render() { + return ( + {x.user ? x.user.name : ''}} + /> + ) + } + }) + + AltIso.render(alt, User, { id: 0, name: 'ZZZZZZ' }).then((markup) => { + assert.match(markup, /ZZZZZZ/) + done() + }) + }, + + 'errors still render the request'() { + const User = AltIso.define((props) => { + return Promise.reject(new Error('oops')) + }, class extends React.Component { + render() { + return JUST TESTING + } + }) + + AltIso.render(alt, User, { id: 0, name: '√∆' }).then((markup) => { + assert.match(markup, /JUST TESTING/) + done() + }) + }, + } +} diff --git a/test/async-test.js b/test/async-test.js index 48663a13..77ecd6cf 100644 --- a/test/async-test.js +++ b/test/async-test.js @@ -112,11 +112,17 @@ class StargazerStore { export default { 'async': { beforeEach() { + global.window = {} + alt.recycle() local.reset() remote.reset() }, + afterEach() { + delete global.window + }, + 'methods are available'() { assert.isFunction(StargazerStore.fetchUsers) assert.isFunction(StargazerStore.isLoading) @@ -211,14 +217,14 @@ export default { 'shouldFetch is true'() { StargazerStore.alwaysFetchUsers() - assert.ok(StargazerStore.isLoading()) - assert.ok(remote.calledOnce) + assert.ok(StargazerStore.isLoading(), 'i am loading') + assert.ok(remote.calledOnce, 'remote was called once') }, 'shouldFetch is false'() { StargazerStore.neverFetchUsers() - assert.notOk(StargazerStore.isLoading()) - assert(remote.callCount === 0) + assert.notOk(StargazerStore.isLoading(), 'loading now') + assert(remote.callCount === 0, 'remote was not called') }, 'multiple loads'(done) { @@ -271,5 +277,44 @@ export default { assert.isFunction(store.justTesting) assert.isFunction(store.isLoading) }, + + 'server rendering'(done) { + delete global.window + + const actions = alt.generateActions('test') + + const PojoSource = { + justTesting: { + remote() { + return Promise.resolve(true) + }, + success: actions.test, + error: actions.test, + } + } + + @datasource(PojoSource) + class MyStore { + static displayName = 'ServerRenderingStore' + } + + const spy = sinon.spy() + + const dispatchToken = alt.dispatcher.register(spy) + + const store = alt.createStore(MyStore) + + store.justTesting().then((value) => { + assert.isFunction(value) + assert(spy.callCount === 0, 'the dispatcher was never called') + + value() + + assert.ok(spy.calledOnce, 'the dispatcher was flushed') + + alt.dispatcher.unregister(dispatchToken) + done() + }) + }, } } From 2513a15fcf167893cff56aeaff6913c1faa338df Mon Sep 17 00:00:00 2001 From: Josh Perez Date: Tue, 2 Jun 2015 10:53:41 -0700 Subject: [PATCH 2/2] Do not render if not locked --- src/utils/Render.js | 4 +++- test/alt-iso-test.js | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utils/Render.js b/src/utils/Render.js index 2878d2a9..7fe9c3d2 100644 --- a/src/utils/Render.js +++ b/src/utils/Render.js @@ -24,7 +24,9 @@ export function withData(fetch, MaybeComponent) { }, render() { - return React.createElement(Component, this.props) + return this.context.buffer.locked + ? React.createElement(Component, this.props) + : null } }) } diff --git a/test/alt-iso-test.js b/test/alt-iso-test.js index 0765a6df..cd4df83d 100644 --- a/test/alt-iso-test.js +++ b/test/alt-iso-test.js @@ -96,8 +96,8 @@ class User extends React.Component { render={(props) => { return (
-

{props.user ? props.user.name : ''}

- {props.user ? props.user.id : 0} +

{props.user.name}

+ {props.user.id}
) }} @@ -164,7 +164,7 @@ export default { return ( {x.user ? x.user.name : ''}} + render={props => {props.user.name}} /> ) }