Last active
March 30, 2017 09:01
-
-
Save sompylasar/5e7157e451f4b7268def9ae1ce01edd4 to your computer and use it in GitHub Desktop.
Server-side rendering loop for universal sagas (redux-saga)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export default function createActionMonitor({ | |
debug = false, | |
}) { | |
let actionMonitorLog = []; | |
const actionMonitorSubscriptions = []; | |
const middleware = () => (next) => (action) => { | |
if ( debug ) { | |
console.log('[actionMonitor] push:', action); // eslint-disable-line | |
} | |
actionMonitorLog = actionMonitorLog.concat([ { | |
...action, | |
// Clear the meta for equality comparison, it may contain the timestamp. | |
meta: null, | |
} ]); | |
actionMonitorSubscriptions.forEach(fn => fn()); | |
return next(action); | |
}; | |
const actionMonitor = { | |
middleware() { | |
return middleware; | |
}, | |
getActions() { | |
return actionMonitorLog; | |
}, | |
reset() { | |
if ( debug ) { | |
console.log('[actionMonitor] reset'); // eslint-disable-line | |
} | |
actionMonitorLog = []; | |
}, | |
subscribe(fn) { | |
if (typeof fn !== 'function') { | |
throw new Error('[actionMonitor] `subscribe` expects a function.'); | |
} | |
actionMonitorSubscriptions.push(fn); | |
return () => { | |
const index = actionMonitorSubscriptions.indexOf(fn); | |
if (index >= 0) { | |
actionMonitorSubscriptions.splice(index, 1); | |
} | |
}; | |
}, | |
}; | |
return actionMonitor; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
eslint-disable | |
semi, | |
no-else-return, | |
no-console, | |
no-use-before-define, | |
brace-style, | |
keyword-spacing, | |
no-nested-ternary, | |
no-multi-spaces, | |
id-length, | |
object-curly-spacing, | |
one-var, | |
one-var-declaration-per-line, | |
array-bracket-spacing, | |
*/ | |
import { is, asEffect } from 'redux-saga/utils' | |
export const EFFECT_STATUS_PENDING = 'PENDING' | |
export const EFFECT_STATUS_RESOLVED = 'RESOLVED' | |
export const EFFECT_STATUS_REJECTED = 'REJECTED' | |
export const EFFECT_STATUS_CANCELLED = 'CANCELLED' | |
const DEFAULT_STYLE = 'color: black' | |
const LABEL_STYLE = 'font-weight: bold' | |
const EFFECT_TYPE_STYLE = 'color: blue' | |
const ERROR_STYLE = 'color: red' | |
const CANCEL_STYLE = 'color: #ccc' | |
const IS_BROWSER = (typeof window !== 'undefined' && window.document) | |
export default function createSagaMonitor({ | |
debug = false, | |
exportToWindow = true, | |
} = {}) { | |
const VERBOSE = debug; | |
const time = () => { | |
if(typeof performance !== 'undefined' && performance.now) | |
return performance.now() | |
else | |
return Date.now() | |
} | |
const effectsById = {} | |
function effectTriggered(desc) { | |
if (VERBOSE) | |
console.log('Saga monitor: effectTriggered:', desc) | |
effectsById[desc.effectId] = Object.assign({}, | |
desc, | |
{ | |
status: EFFECT_STATUS_PENDING, | |
start: time() | |
} | |
) | |
} | |
function effectResolved(effectId, result) { | |
if (VERBOSE) | |
console.log('Saga monitor: effectResolved:', effectId, result) | |
resolveEffect(effectId, result) | |
} | |
function effectRejected(effectId, error) { | |
if (VERBOSE) | |
console.log('Saga monitor: effectRejected:', effectId, error) | |
rejectEffect(effectId, error) | |
} | |
function effectCancelled(effectId) { | |
if (VERBOSE) | |
console.log('Saga monitor: effectCancelled:', effectId) | |
cancelEffect(effectId) | |
} | |
function computeEffectDur(effect) { | |
const now = time() | |
Object.assign(effect, { | |
end: now, | |
duration: now - effect.start | |
}) | |
} | |
function resolveEffect(effectId, result) { | |
const effect = effectsById[effectId] | |
if(is.task(result)) { | |
result.done.then( | |
taskResult => { | |
if(result.isCancelled()) | |
cancelEffect(effectId) | |
else | |
resolveEffect(effectId, taskResult) | |
}, | |
taskError => rejectEffect(effectId, taskError) | |
) | |
} else { | |
computeEffectDur(effect) | |
effect.status = EFFECT_STATUS_RESOLVED | |
effect.result = result | |
if(effect && asEffect.race(effect.effect)) | |
setRaceWinner(effectId, result) | |
} | |
} | |
function rejectEffect(effectId, error) { | |
const effect = effectsById[effectId] | |
computeEffectDur(effect) | |
effect.status = EFFECT_STATUS_REJECTED | |
effect.error = error | |
if(effect && asEffect.race(effect.effect)) | |
setRaceWinner(effectId, error) | |
} | |
function cancelEffect(effectId) { | |
const effect = effectsById[effectId] | |
computeEffectDur(effect) | |
effect.status = EFFECT_STATUS_CANCELLED | |
} | |
function setRaceWinner(raceEffectId, result) { | |
const winnerLabel = Object.keys(result)[0] | |
const children = getChildEffects(raceEffectId) | |
for(let i = 0; i < children.length; i++) { | |
const childEffect = effectsById[ children[i] ] | |
if (childEffect.label === winnerLabel) | |
childEffect.winner = true | |
} | |
} | |
function getChildEffects(parentEffectId) { | |
return Object.keys(effectsById) | |
.filter(effectId => effectsById[effectId].parentEffectId === parentEffectId) | |
.map(effectId => +effectId) | |
} | |
// Poor man's `console.group` and `console.groupEnd` for Node. | |
// Can be overridden by the `console-group` polyfill. | |
// The poor man's groups look nice, too, so whether to use | |
// the polyfilled methods or the hand-made ones can be made a preference. | |
let groupPrefix = ''; | |
const GROUP_SHIFT = ' '; | |
const GROUP_ARROW = '▼'; | |
function consoleGroup(...args) { | |
if(console.group) | |
console.group(...args) | |
else { | |
console.log('') | |
console.log(groupPrefix + GROUP_ARROW, ...args) | |
groupPrefix += GROUP_SHIFT | |
} | |
} | |
function consoleGroupEnd() { | |
if(console.groupEnd) | |
console.groupEnd() | |
else | |
groupPrefix = groupPrefix.substr(0, groupPrefix.length - GROUP_SHIFT.length) | |
} | |
function logEffectTree(effectId) { | |
const effect = effectsById[effectId] | |
if(effectId === undefined) { | |
console.log(groupPrefix, 'Saga monitor: No effect data for', effectId) | |
return | |
} | |
const childEffects = getChildEffects(effectId) | |
if(!childEffects.length) | |
logSimpleEffect(effect) | |
else { | |
if(effect) { | |
const {formatter} = getEffectLog(effect) | |
consoleGroup(...formatter.getLog()) | |
} else | |
consoleGroup('root') | |
childEffects.forEach(logEffectTree) | |
consoleGroupEnd() | |
} | |
} | |
function logSimpleEffect(effect) { | |
const {method, formatter} = getEffectLog(effect) | |
console[method](...formatter.getLog()) | |
} | |
/* eslint-disable no-cond-assign */ | |
function getEffectLog(effect) { | |
let data, log | |
if(data = asEffect.take(effect.effect)) { | |
log = getLogPrefix('take', effect) | |
log.formatter.addValue(data) | |
logResult(effect, log.formatter) | |
} | |
else if(data = asEffect.put(effect.effect)) { | |
log = getLogPrefix('put', effect) | |
logResult(Object.assign({}, effect, { result: data }), log.formatter) | |
} | |
else if(data = asEffect.call(effect.effect)) { | |
log = getLogPrefix('call', effect) | |
log.formatter.addCall(data.fn.name, data.args) | |
logResult(effect, log.formatter) | |
} | |
else if(data = asEffect.cps(effect.effect)) { | |
log = getLogPrefix('cps', effect) | |
log.formatter.addCall(data.fn.name, data.args) | |
logResult(effect, log.formatter) | |
} | |
else if(data = asEffect.fork(effect.effect)) { | |
log = getLogPrefix('', effect) | |
log.formatter.addCall(data.fn.name, data.args) | |
logResult(effect, log.formatter) | |
} | |
else if(data = asEffect.join(effect.effect)) { | |
log = getLogPrefix('join', effect) | |
logResult(effect, log.formatter) | |
} | |
else if(data = asEffect.race(effect.effect)) { | |
log = getLogPrefix('race', effect) | |
logResult(effect, log.formatter, true) | |
} | |
else if(data = asEffect.cancel(effect.effect)) { | |
log = getLogPrefix('cancel', effect) | |
log.formatter.appendData(data.name) | |
} | |
else if(data = asEffect.select(effect.effect)) { | |
log = getLogPrefix('select', effect) | |
log.formatter.addCall(data.selector.name, data.args) | |
logResult(effect, log.formatter) | |
} | |
else if(is.array(effect.effect)) { | |
log = getLogPrefix('parallel', effect) | |
logResult(effect, log.formatter, true) | |
} | |
else if(is.iterator(effect.effect)) { | |
log = getLogPrefix('', effect) | |
log.formatter.addValue(effect.effect.name) | |
logResult(effect, log.formatter, true) | |
} | |
else { | |
log = getLogPrefix('unkown', effect) | |
logResult(effect, log.formatter) | |
} | |
return log | |
} | |
function getLogPrefix(type, effect) { | |
const isCancel = effect.status === EFFECT_STATUS_CANCELLED | |
const isError = effect.status === EFFECT_STATUS_REJECTED | |
const method = isError ? 'error' : 'log' | |
const winnerInd = effect && effect.winner | |
? ( isError ? '✘' : '✓' ) | |
: '' | |
const style = (s) => ( | |
isCancel ? CANCEL_STYLE | |
: isError ? ERROR_STYLE | |
: s | |
) | |
const formatter = logFormatter() | |
if(winnerInd) | |
formatter.add(`%c ${winnerInd}`, style(LABEL_STYLE)) | |
if(effect && effect.label) | |
formatter.add(`%c ${effect.label}: `, style(LABEL_STYLE)) | |
if(type) | |
formatter.add(`%c ${type} `, style(EFFECT_TYPE_STYLE)) | |
formatter.add('%c', style(DEFAULT_STYLE)) | |
return { | |
method, | |
formatter | |
} | |
} | |
function argToString(arg) { | |
return ( | |
typeof arg === 'function' ? `${arg.name}` | |
: typeof arg === 'string' ? `'${arg}'` | |
: arg | |
) | |
} | |
function logResult({status, result, error, duration}, formatter, ignoreResult) { | |
if(status === EFFECT_STATUS_RESOLVED && !ignoreResult) { | |
if( is.array(result) ) { | |
formatter.addValue(' → ') | |
formatter.addValue(result) | |
} else | |
formatter.appendData('→', result) | |
} | |
else if(status === EFFECT_STATUS_REJECTED) { | |
formatter.appendData('→ ⚠', error) | |
} | |
else if(status === EFFECT_STATUS_PENDING) | |
formatter.appendData('⌛') | |
else if(status === EFFECT_STATUS_CANCELLED) | |
formatter.appendData('→ Cancelled!') | |
if(status !== EFFECT_STATUS_PENDING) | |
formatter.appendData(`(${duration.toFixed(2)}ms)`) | |
} | |
function isPrimitive(val) { | |
return typeof val === 'string' || | |
typeof val === 'number' || | |
typeof val === 'boolean' || | |
typeof val === 'symbol' || | |
val === null || | |
val === undefined; | |
} | |
function logFormatter() { | |
const logs = [] | |
let suffix = [] | |
function add(msg, ...args) { | |
// Remove the `%c` CSS styling that is not supported by the Node console. | |
if(!IS_BROWSER && typeof msg === 'string') { | |
const prevMsg = msg | |
msg = msg.replace(/^%c\s*/, '') | |
if(msg !== prevMsg) { | |
// Remove the first argument which is the CSS style string. | |
args.shift() | |
} | |
} | |
logs.push({msg, args}) | |
} | |
function appendData(...data) { | |
suffix = suffix.concat(data) | |
} | |
function addValue(value) { | |
if(isPrimitive(value)) | |
add(value) | |
else { | |
// The browser console supports `%O`, the Node console does not. | |
if(IS_BROWSER) | |
add('%O', value) | |
else | |
add('%s', require('util').inspect(value)) | |
} | |
} | |
function addCall(name, args) { | |
if(!args.length) | |
add( `${name}()` ) | |
else { | |
add(name) | |
add('(') | |
args.forEach( (arg, i) => { | |
addValue( argToString(arg) ) | |
addValue( i === args.length - 1 ? ')' : ', ') | |
}) | |
} | |
} | |
function getLog() { | |
const msgs = [] | |
let msgsArgs = [] | |
for(let i = 0; i < logs.length; i++) { | |
msgs.push(logs[i].msg) | |
msgsArgs = msgsArgs.concat(logs[i].args) | |
} | |
return [msgs.join('')].concat(msgsArgs).concat(suffix) | |
} | |
return { | |
add, addValue, addCall, appendData, getLog | |
} | |
} | |
/** | |
* The snapshot-logging function for arbitrary use by external code. | |
*/ | |
const logSaga = () => { | |
console.log('') | |
console.log('Saga monitor:', Date.now(), (new Date()).toISOString()) | |
logEffectTree(0) | |
console.log('') | |
} | |
/** | |
* Returns all the effects hashed by identifier. | |
* | |
* @return {Object<string,SagaMonitorEffectDescriptor>} The effects hashed by identifier. | |
*/ | |
const getEffectsById = () => { | |
return effectsById; | |
} | |
/** | |
* Returns effects that currently block the saga promise from resolving. | |
* | |
* @return {Array<SagaMonitorEffectDescriptor>} The effects array. | |
*/ | |
const getBlockingEffectsArray = () => { | |
// Skip FORK and TAKE because they are auto-interrupted by the END action. | |
const blockingEffects = Object.keys(effectsById) | |
.filter(effectId => ( | |
effectsById[effectId].status === EFFECT_STATUS_PENDING && | |
!asEffect.fork(effectsById[effectId].effect) && !asEffect.take(effectsById[effectId].effect) | |
)) | |
.map(effectId => effectsById[effectId]) | |
return blockingEffects; | |
} | |
// The `sagaMonitor` to pass to the middleware. | |
const sagaMonitor = { | |
// Middleware interface. | |
effectTriggered, | |
effectResolved, | |
effectRejected, | |
effectCancelled, | |
// Utility functions. | |
getEffectsById, | |
getBlockingEffectsArray, | |
logSaga, | |
}; | |
// Export the snapshot-logging function to run from the browser console or extensions. | |
if(IS_BROWSER && exportToWindow) { | |
window.$$LogSagas = logSaga | |
} | |
return sagaMonitor; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import ReactDOM from 'react-dom/server'; | |
import lodashIsEqual from 'lodash/isEqual'; | |
/** | |
* The render loop ensures all server-side processing has finished, | |
* and the application has reached a stable state. | |
* | |
* This technique enables specifying data demands in `componentWillMount` | |
* as dispatches of data request actions which are processed in sagas | |
* the same way this is done on the browser-side. | |
* | |
* @param {ReduxStore} store Must have `endSaga`, `actionMonitor`, `sagaMonitor`. | |
* @param {ReactElement} renderDOM The element to render to string. | |
* @param {Promise} options.sagaPromise The root saga `Task` promise. | |
* @param {number} [options.maxRenders=15] The limit for the number of server-side renders. | |
* @param {number} [options.timeout=5000] The timeout for the render to finish. | |
* @param {boolean} [options.debug=false] Enable debug log output. | |
* @return {Promise<string, Error>} Resolves to the rendered HTML string. | |
*/ | |
function runRenderLoop({ | |
store, | |
renderDOM, | |
sagaPromise, | |
maxRenders = 15, | |
timeout = 5000, | |
debug = false, | |
}) { | |
if ( !store || !store.endSaga || !store.actionMonitor || !store.sagaMonitor ) { | |
throw new Error('The `store` must have `endSaga`, `actionMonitor`, `sagaMonitor`.'); | |
} | |
if ( !sagaPromise || !sagaPromise.then ) { | |
throw new Error('The `sagaPromise` must be a Promise.'); | |
} | |
return new Promise((resolve, reject) => { | |
const serverRenderLoopContext = { | |
debug: debug, | |
blockingEffectsCountInitial: store.sagaMonitor.getBlockingEffectsArray().length, | |
actionsDispatchedDuringRender: [], | |
renderedString: '', | |
unsubscribe: null, | |
serverRenderLoopTimer: null, | |
rendersRemaining: maxRenders, | |
renderIsActive: false, | |
terminated: false, | |
timeoutTimer: null, | |
}; | |
function debugLog(...args) { | |
if ( serverRenderLoopContext.debug ) { | |
console.log.apply(console, args); // eslint-disable-line no-console | |
} | |
} | |
function cancelRenderLoopFrame() { | |
clearTimeout(serverRenderLoopContext.serverRenderLoopTimer); | |
serverRenderLoopContext.serverRenderLoopTimer = null; | |
} | |
function requestRenderLoopFrame(fn) { | |
// Debounce to prevent re-render until all the synchronously dispatched actions are processed. | |
clearTimeout(serverRenderLoopContext.serverRenderLoopTimer); | |
serverRenderLoopContext.serverRenderLoopTimer = setTimeout(() => { | |
serverRenderLoopContext.serverRenderLoopTimer = null; | |
// Do not continue if currently rendering. | |
if ( serverRenderLoopContext.renderIsActive ) { | |
debugLog('[serverRenderLoop] renderIsActive in requestRenderLoopFrame setTimeout'); | |
return; | |
} | |
// Do not continue if already terminated. | |
if ( serverRenderLoopContext.terminated ) { | |
debugLog('[serverRenderLoop] terminated in requestRenderLoopFrame setTimeout'); | |
return; | |
} | |
fn(); | |
}, 1); | |
} | |
function terminate() { | |
if ( serverRenderLoopContext.terminated ) { | |
debugLog('[serverRenderLoop] terminated in terminate'); | |
return; | |
} | |
debugLog('[serverRenderLoop] terminate executing...'); | |
serverRenderLoopContext.terminated = true; | |
const { | |
timeoutTimer, | |
unsubscribe, | |
} = serverRenderLoopContext; | |
clearTimeout(timeoutTimer); | |
if ( unsubscribe ) { | |
unsubscribe(); | |
} | |
cancelRenderLoopFrame(); | |
// Dispatch the saga END action. | |
store.endSaga(); | |
if ( serverRenderLoopContext.debug ) { | |
debugLog('[serverRenderLoop] terminate done, waiting saga...'); | |
} | |
// Wait for the saga to terminate. | |
sagaPromise.then(() => { | |
store.actionMonitor.reset(); | |
debugLog('[serverRenderLoop] saga done, resolving...'); | |
resolve(serverRenderLoopContext.renderedString); | |
debugLog('[serverRenderLoop] resolved.'); | |
}).catch((error) => { | |
debugLog('[serverRenderLoop] saga error, rejecting...', error); | |
reject(error); | |
debugLog('[serverRenderLoop] rejected.'); | |
}); | |
} | |
const renderLoop = () => { | |
requestRenderLoopFrame(() => { | |
// Check if different actions have been dispatched during render. | |
const actionsDispatchedDuringRenderPrev = serverRenderLoopContext.actionsDispatchedDuringRender; | |
const actionsDispatchedDuringRender = store.actionMonitor.getActions(); | |
const hasNewActions = ( | |
actionsDispatchedDuringRender.length > 0 && | |
!lodashIsEqual(actionsDispatchedDuringRenderPrev, actionsDispatchedDuringRender) | |
); | |
serverRenderLoopContext.actionsDispatchedDuringRender = actionsDispatchedDuringRender; | |
debugLog( | |
'[serverRenderLoop] hasNewActions:', hasNewActions, | |
'\n[serverRenderLoop] actionsDispatchedDuringRenderPrev:', actionsDispatchedDuringRenderPrev, | |
'\n[serverRenderLoop] actionsDispatchedDuringRender:', actionsDispatchedDuringRender, | |
'\n' | |
); | |
if ( hasNewActions ) { | |
serverRenderLoopContext.rendersRemaining--; | |
debugLog('[serverRenderLoop] rendersRemaining decremented:', serverRenderLoopContext.rendersRemaining); | |
// Reset action monitor for this render. | |
store.actionMonitor.reset(); | |
debugLog('[serverRenderLoop] renderToString begin'); | |
serverRenderLoopContext.renderIsActive = true; | |
serverRenderLoopContext.renderedString = ReactDOM.renderToString( renderDOM ); | |
serverRenderLoopContext.renderIsActive = false; | |
debugLog('[serverRenderLoop] renderToString end'); | |
} | |
const rendersRemaining = serverRenderLoopContext.rendersRemaining; | |
debugLog('[serverRenderLoop] rendersRemaining:', rendersRemaining); | |
// Check if blocking effects are active. | |
const blockingEffects = store.sagaMonitor.getBlockingEffectsArray(); | |
const blockingEffectsCount = blockingEffects.length; | |
const blockingEffectsCountInitial = serverRenderLoopContext.blockingEffectsCountInitial; | |
const hasBlockingEffects = (blockingEffectsCount > blockingEffectsCountInitial); | |
debugLog( | |
'[serverRenderLoop] hasBlockingEffects:', hasBlockingEffects, | |
'\n[serverRenderLoop] blockingEffects:', blockingEffects, | |
'\n[serverRenderLoop] blockingEffectsCount:', blockingEffectsCount, | |
'\n[serverRenderLoop] blockingEffectsCountInitial:', blockingEffectsCountInitial, | |
'\n' | |
); | |
if ( rendersRemaining <= 0 || ( !hasNewActions && !hasBlockingEffects ) ) { | |
debugLog( | |
'[serverRenderLoop] terminating by state...', | |
'\n[serverRenderLoop] rendersRemaining:', rendersRemaining, | |
'\n[serverRenderLoop] hasNewActions:', hasNewActions, | |
'\n[serverRenderLoop] hasBlockingEffects:', hasBlockingEffects, | |
'\n' | |
); | |
terminate(); | |
return; | |
} | |
else if ( hasBlockingEffects ) { | |
// Wait for more actions after blocking effects resolve. | |
// We expect that the sagas unconditionally dispatch an action after every asynchronous effect. | |
return; | |
} | |
// Render once again to handle the remaining actions. | |
renderLoop(); | |
}); | |
}; | |
// Ensure the render terminates within certain time. | |
if ( timeout > 0 ) { | |
debugLog('[serverRenderLoop] timeout configured: ' + timeout); | |
serverRenderLoopContext.timeoutTimer = setTimeout(() => { | |
debugLog('[serverRenderLoop] terminating by timeout...'); | |
terminate(); | |
}, timeout); | |
} | |
debugLog('[serverRenderLoop] subscribing to actionMonitor...'); | |
serverRenderLoopContext.unsubscribe = store.actionMonitor.subscribe(renderLoop); | |
renderLoop(); | |
}); | |
} | |
export default runRenderLoop; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ===== in createStore.js | |
import createSagaMiddleware, { END as REDUX_SAGA_END } from 'redux-saga'; | |
import createActionMonitor from './actionMonitor'; | |
import createSagaMonitor from './sagaMonitor'; | |
export default function createStore( /* ... */ ) { | |
const middleware = []; | |
// ... | |
// `actionMonitor` and `endSaga` required for the `serverRenderLoop` on __SERVER__. | |
let actionMonitor; | |
if ( __SERVER__ ) { | |
actionMonitor = createActionMonitor({ | |
debug: ( __DEVELOPMENT__ && false ), | |
}); | |
middleware.push(actionMonitor.middleware()); | |
} | |
// `sagaMonitor` is required for the `serverRenderLoop` on __SERVER__. | |
// We keep `sagaMonitor` on the client for debugging. | |
let sagaMonitor; | |
if ( __DEVELOPMENT__ || __SERVER__ ) { | |
sagaMonitor = createSagaMonitor({ | |
debug: ( __DEVELOPMENT__ && false ), | |
}); | |
} | |
const sagaMiddleware = createSagaMiddleware({ | |
sagaMonitor: sagaMonitor, | |
}); | |
middleware.push(sagaMiddleware); | |
// ... | |
store.runSaga = sagaMiddleware.run; | |
// `sagaMonitor` is required for the `serverRenderLoop` on __SERVER__. | |
// We keep `sagaMonitor` on the client for debugging. | |
if ( __DEVELOPMENT__ || __SERVER__ ) { | |
store.sagaMonitor = sagaMonitor; | |
} | |
// `actionMonitor` and `endSaga` required for the `serverRenderLoop` on __SERVER__. | |
if ( __SERVER__ ) { | |
store.endSaga = () => store.dispatch(REDUX_SAGA_END); | |
store.actionMonitor = actionMonitor; | |
} | |
// ... | |
return store; | |
} | |
// ===== in server.js | |
// Serve Redux universal app. | |
app.use((req, res) => { | |
// ... | |
const store = createStore( /* ... */ ); | |
const sagaPromise = store.runSaga(rootSaga, /* ... */ ).done; | |
// ... | |
const renderDOM = ( | |
<ReduxStoreProvider store={store} key="provider"> | |
{ /* ... */ } | |
</ReduxStoreProvider> | |
); | |
return runRenderLoop({ | |
store, | |
renderDOM, | |
sagaPromise, | |
}).then((renderedString) => { | |
// ... | |
const responseHtml = ( | |
'<!doctype html>' + | |
'<html>' + | |
// ... | |
'<div id="react-root">' + renderedString + '</div>' + | |
// ... | |
'</html>' | |
); | |
// ... | |
res.set('Content-Type', 'text/html'); | |
res.end( responseHtml ); | |
}); | |
// ... | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment