diff --git a/package.json b/package.json index c5d7aeef..e4420155 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "redux-saga": "^0.14.3", "sanitize.css": "^4.1.0", "sharetribe-scripts": "0.8.6", - "source-map-support": "^0.4.11" + "source-map-support": "^0.4.11", + "url": "^0.11.0" }, "devDependencies": { "nodemon": "^1.11.0", diff --git a/public/index.html b/public/index.html index d095f837..edc9c714 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ - +
diff --git a/server/fakeSDK.js b/server/fakeSDK.js new file mode 100644 index 00000000..86effa36 --- /dev/null +++ b/server/fakeSDK.js @@ -0,0 +1,35 @@ +/* eslint-disable import/prefer-default-export, no-unused-vars */ +const fakeListings = [ + { + id: 123, + title: 'Banyan Studios', + price: '55\u20AC / day', + description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.', + location: 'New York, NY \u2022 40mi away', + review: { count: '8 reviews', rating: '4' }, + author: { + name: 'The Stardust Collective', + avatar: 'http://placehold.it/44x44', + review: { rating: '4' }, + }, + }, + { + id: 1234, + title: 'Pienix Studio', + price: '80\u20AC / day', + description: 'Pienix Studio specializes in music mixing and mastering production.', + location: 'New York, NY \u2022 6mi away', + review: { count: '7 reviews', rating: '4' }, + author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } }, + }, +]; +/* eslint-enable import/prefer-default-export */ + +exports.fetchListings = () => { + const randomTime = Math.random() * 2000; + const fakeResponseTime = 1000 + randomTime; + + return new Promise((resolve, reject) => { + setTimeout(() => resolve(fakeListings), fakeResponseTime); + }); +}; diff --git a/server/index.js b/server/index.js index a2cc2d6c..9670ce53 100644 --- a/server/index.js +++ b/server/index.js @@ -22,10 +22,13 @@ const compression = require('compression'); const path = require('path'); const fs = require('fs'); const qs = require('qs'); +const url = require('url'); const _ = require('lodash'); const React = require('react'); const { createServerRenderContext } = require('react-router'); +const sagaEffects = require('redux-saga/effects'); const auth = require('./auth'); +const sdk = require('./fakeSDK'); // Construct the bundle path where the server side rendering function // can be imported. @@ -33,7 +36,10 @@ const buildPath = path.resolve(__dirname, '..', 'build'); const manifestPath = path.join(buildPath, 'asset-manifest.json'); const manifest = require(manifestPath); const mainJsPath = path.join(buildPath, manifest['main.js']); -const renderApp = require(mainJsPath).default; +const mainJs = require(mainJsPath); +const renderApp = mainJs.default; +const matchPathname = mainJs.matchPathname; +const configureStore = mainJs.configureStore; // The HTML build file is generated from the `public/index.html` file // and used as a template for server side rendering. The application @@ -66,15 +72,52 @@ const template = _.template(indexHtml, { escape: reNoMatch, }); -function render(url, context, preloadedState) { - const { head, body } = renderApp(url, context, preloadedState); +function fetchInitialState(requestUrl) { + const pathname = url.parse(requestUrl).pathname; + const { matchedRoutes, params } = matchPathname(pathname); + + // pathname may match with several routes (if they don't have exactly=true) + // We filter all the components form matched routes that have `loadData` + const initialFetches = _ + .chain(matchedRoutes) + .filter(r => r.loadData) + .map(r => sagaEffects.fork(r.loadData, sdk)) + .value(); + + // We need to combine different onload sagas under one yield + const fetchInitialData = function* fetchInitialData() { + yield initialFetches; + }; + + // runSaga (if necessary) and return initial store state after loadData fetches. + if (initialFetches.length > 0) { + const fetchPromise = new Promise((resolve, reject) => { + const store = configureStore({}); + store.runSaga(fetchInitialData).done + .then(() => { + resolve(store.getState()); + }) + .catch(e => { + reject(e); + }); + // Close temporary store's saga middleware (which was used only for fetching initial store state) + store.closeSagaMiddleware(); + }); + return fetchPromise; + } + return Promise.resolve({}); +} + +function render(requestUrl, context, preloadedState) { + const { head, body } = renderApp(requestUrl, context, preloadedState); // Preloaded state needs to be passed for client side too. // For security reasons we ensure that preloaded state is considered as a string // by replacing '<' character with its unicode equivalent. // http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations + const serializedState = JSON.stringify(preloadedState).replace(/window.__PRELOADED_STATE__ = ${serializedState}; `; return template({ title: head.title.toString(), preloadedStateScript, body }); @@ -104,21 +147,26 @@ app.get('*', (req, res) => { const filters = qs.parse(req.query); // TODO fetch this asynchronously - const preloadedState = { search: { filters } }; + fetchInitialState(req.url) + .then(preloadedState => { + const html = render(req.url, context, preloadedState); + const result = context.getResult(); - const html = render(req.url, context, preloadedState); - const result = context.getResult(); - - if (result.redirect) { - res.redirect(result.redirect.pathname); - } else if (result.missed) { - // Do a second render pass with the context to clue