Skip to content

Commit 87f4b5c

Browse files
authored
SSR: Cache State, Take Two (#13339)
Previously, controllers of SSR'd sections were responsible for caching API data. But there's a better way: We're already sending Redux state to the client at server-render time, so why not also cache on the server side, and use that as the initial state when creating the Redux store for the next request (given that this is in logged-out mode, i.e. the cache key -- the route -- maps one-to-one to the state, with the exception of errors like 404s, where one error page can be enough for different invalid routes). Furthermore, this PR removes `renderCacheKey` and just uses `context.pathname` for _markup_ cache. Previously, we were using concatenated path and API data (~state) cache timestamp as key. Creating the markup cache key isn't the section controller's responsibility now.
1 parent 02d0532 commit 87f4b5c

File tree

7 files changed

+78
-76
lines changed

7 files changed

+78
-76
lines changed

client/my-sites/theme/controller.jsx

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import React from 'react';
55
import { Provider as ReduxProvider } from 'react-redux';
66
import debugFactory from 'debug';
7-
import Lru from 'lru';
87
import startsWith from 'lodash/startsWith';
98

109
/**
@@ -14,32 +13,24 @@ import ThemeSheetComponent from './main';
1413
import ThemeNotFoundError from './theme-not-found-error';
1514
import LayoutLoggedOut from 'layout/logged-out';
1615
import {
17-
receiveTheme,
1816
requestTheme,
1917
setBackPath
2018
} from 'state/themes/actions';
2119
import { getTheme, getThemeRequestErrors } from 'state/themes/selectors';
2220
import config from 'config';
2321

2422
const debug = debugFactory( 'calypso:themes' );
25-
const HOUR_IN_MS = 3600000;
26-
const themeDetailsCache = new Lru( {
27-
max: 500,
28-
maxAge: HOUR_IN_MS
29-
} );
3023

3124
export function fetchThemeDetailsData( context, next ) {
3225
if ( ! config.isEnabled( 'manage/themes/details' ) || ! context.isServerSide ) {
3326
return next();
3427
}
3528

3629
const themeSlug = context.params.slug;
37-
const theme = themeDetailsCache.get( themeSlug );
30+
const theme = getTheme( context.store.getState(), 'wpcom', themeSlug );
3831

3932
if ( theme ) {
4033
debug( 'found theme!', theme.id );
41-
context.store.dispatch( receiveTheme( theme, 'wpcom' ) );
42-
context.renderCacheKey = context.path + theme.timestamp;
4334
return next();
4435
}
4536

@@ -49,18 +40,13 @@ export function fetchThemeDetailsData( context, next ) {
4940
if ( ! themeDetails ) {
5041
const error = getThemeRequestErrors( context.store.getState(), themeSlug, 'wpcom' );
5142
debug( `Error fetching theme ${ themeSlug } details: `, error.message || error );
52-
context.renderCacheKey = 'theme not found';
5343
const err = {
5444
status: 404,
5545
message: 'Theme Not Found',
5646
themeSlug
5747
};
5848
return next( err );
5949
}
60-
debug( 'caching', themeSlug );
61-
themeDetails.timestamp = Date.now();
62-
context.renderCacheKey = context.path + themeDetails.timestamp;
63-
themeDetailsCache.set( themeSlug, themeDetails );
6450
next();
6551
} )
6652
.catch( next );

client/my-sites/themes/controller.jsx

Lines changed: 10 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
*/
44
import { compact, includes, isEmpty, startsWith } from 'lodash';
55
import debugFactory from 'debug';
6-
import Lru from 'lru';
76
import React from 'react';
87

98
/**
@@ -15,22 +14,12 @@ import LoggedOutComponent from './logged-out';
1514
import Upload from 'my-sites/themes/theme-upload';
1615
import trackScrollPage from 'lib/track-scroll-page';
1716
import { DEFAULT_THEME_QUERY } from 'state/themes/constants';
18-
import { requestThemes, requestThemeFilters, receiveThemes, setBackPath } from 'state/themes/actions';
19-
import { getThemesForQuery, getThemesFoundForQuery } from 'state/themes/selectors';
17+
import { requestThemes, requestThemeFilters, setBackPath } from 'state/themes/actions';
18+
import { getThemesForQuery } from 'state/themes/selectors';
2019
import { getAnalyticsData } from './helpers';
2120
import { getThemeFilters } from 'state/selectors';
22-
import { THEME_FILTERS_ADD } from 'state/action-types';
2321

2422
const debug = debugFactory( 'calypso:themes' );
25-
const HOUR_IN_MS = 3600000;
26-
const themesQueryCache = new Lru( {
27-
max: 500,
28-
maxAge: HOUR_IN_MS
29-
} );
30-
31-
// The filters cache is straight-forward since requestThemeFilters() always returns the same
32-
// list of all available filters.
33-
let filters = {};
3423

3524
function getProps( context ) {
3625
const { tier, filter, vertical, site_id: siteId } = context.params;
@@ -110,11 +99,6 @@ export function fetchThemeData( context, next ) {
11099
return next();
111100
}
112101

113-
if ( ! isEmpty( context.query ) ) {
114-
// Don't server-render URLs with query params
115-
return next();
116-
}
117-
118102
const siteId = 'wpcom';
119103
const query = {
120104
search: context.query.s,
@@ -123,43 +107,26 @@ export function fetchThemeData( context, next ) {
123107
page: 1,
124108
number: DEFAULT_THEME_QUERY.number,
125109
};
126-
// context.pathname includes tier, filter, and verticals, but not the query string, so it's a suitable cacheKey
127-
// However, we can't guarantee it's normalized -- filters can be in any order, resulting in multiple possible cacheKeys for
128-
// the same sets of results.
129-
const cacheKey = context.pathname;
130-
131-
const cachedData = themesQueryCache.get( cacheKey );
132-
if ( cachedData ) {
133-
debug( `found theme data in cache key=${ cacheKey }` );
134-
context.store.dispatch( receiveThemes( cachedData.themes, siteId, query, cachedData.found ) );
135-
context.renderCacheKey = cacheKey + cachedData.timestamp;
110+
111+
const themes = getThemesForQuery( context.store.getState(), siteId, query );
112+
if ( themes ) {
113+
debug( 'found theme data in cache' );
136114
return next();
137115
}
138116

139-
context.store.dispatch( requestThemes( siteId, query ) )
140-
.then( () => {
141-
const themes = getThemesForQuery( context.store.getState(), siteId, query );
142-
const found = getThemesFoundForQuery( context.store.getState(), siteId, query );
143-
const timestamp = Date.now();
144-
themesQueryCache.set( cacheKey, { themes, found, timestamp } );
145-
context.renderCacheKey = cacheKey + timestamp;
146-
debug( `caching theme data key=${ cacheKey }` );
147-
next();
148-
} )
149-
.catch( () => next() );
117+
context.store.dispatch( requestThemes( siteId, query ) ).then( next ).catch( next );
150118
}
151119

152120
export function fetchThemeFilters( context, next ) {
153121
const { store } = context;
154-
if ( ! isEmpty( filters ) ) {
122+
123+
if ( ! isEmpty( getThemeFilters( store.getState() ) ) ) {
155124
debug( 'found theme filters in cache' );
156-
store.dispatch( { type: THEME_FILTERS_ADD, filters } );
157125
return next();
158126
}
159127

160128
const unsubscribe = store.subscribe( () => {
161-
filters = getThemeFilters( store.getState() );
162-
if ( ! isEmpty( filters ) ) {
129+
if ( ! isEmpty( getThemeFilters( store.getState() ) ) ) {
163130
unsubscribe();
164131
return next();
165132
}

docs/server-side-rendering.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ React components used on the server will be rendered to HTML by being passed to
2222

2323
### Caching
2424

25-
Because it is necessary to serve the redux state along with a server-rendered page, we use two levels of cache on the server: one to store raw query data, from which we can generate and serve redux state, and one to store rendered layouts.
25+
Because it is necessary to serve the redux state along with a server-rendered page, we use two levels of cache on the server: one to store the redux state, and one to store rendered layouts.
2626

2727
##### Data Cache
2828

29-
Caching data is currently left to the controller for a [given](../client/my-sites/themes/controller.jsx) [section](../client/my-sites/theme/controller.jsx). Request timestamps are used to force expiration.
29+
At render time, the Redux state is [serialized and cached](../server/render/index.js), using the current path as the cache key, unless there is a query string, in which case we don't cache.
30+
31+
This means that all data that was fetched to render a given page is available the next time the corresponding route is hit. A section controller thus only needs to check if the required data is available (using selectors), and dispatch the corresponding fetching action if it isn't; see the [themes controller](../client/my-sites/themes/controller.jsx) for an example.
3032

3133
##### Render Cache
3234

server/isomorphic-routing/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export function serverRouter( expressApp, setUpRoute, section ) {
1717
route( err, req.context, next );
1818
},
1919
// We need 4 args so Express knows this is an error-handling middleware
20+
// TODO: Ideally, there'd be a dedicated serverRenderError middleware in server/render
2021
( err, req, res, next ) => { // eslint-disable-line no-unused-vars
22+
req.error = err;
2123
serverRender( req, res.status( err.status ) );
2224
}
2325
);

server/pages/index.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import qs from 'qs';
88
import { execSync } from 'child_process';
99
import cookieParser from 'cookie-parser';
1010
import debugFactory from 'debug';
11-
import { get } from 'lodash';
11+
import { get, isEmpty, pick } from 'lodash';
1212

1313
/**
1414
* Internal dependencies
@@ -19,7 +19,9 @@ import utils from 'bundler/utils';
1919
import sectionsModule from '../../client/sections';
2020
import { serverRouter } from 'isomorphic-routing';
2121
import { serverRender } from 'render';
22-
import { createReduxStore } from 'state';
22+
import stateCache from 'state-cache';
23+
import { createReduxStore, reducer } from 'state';
24+
import { DESERIALIZE } from 'state/action-types';
2325

2426
const debug = debugFactory( 'calypso:pages' );
2527

@@ -38,6 +40,13 @@ const staticFiles = [
3840

3941
const sections = sectionsModule.get();
4042

43+
// TODO: Re-use (a modified version of) client/state/initial-state#getInitialServerState here
44+
function getInitialServerState( serializedServerState ) {
45+
// Bootstrapped state from a server-render
46+
const serverState = reducer( serializedServerState, { type: DESERIALIZE } );
47+
return pick( serverState, Object.keys( serializedServerState ) );
48+
}
49+
4150
/**
4251
* Generates a hash of a files contents to be used as a version parameter on asset requests.
4352
* @param {String} path Path to file we want to hash
@@ -111,6 +120,14 @@ function getCurrentCommitShortChecksum() {
111120
}
112121

113122
function getDefaultContext( request ) {
123+
let initialServerState = {};
124+
// We don't cache routes with query params
125+
if ( isEmpty( request.query ) ) {
126+
// context.pathname is set to request.path, see server/isomorphic-routing#getEnhancedContext()
127+
const serializeCachedServerState = stateCache.get( request.path ) || {};
128+
initialServerState = getInitialServerState( serializeCachedServerState );
129+
}
130+
114131
const context = Object.assign( {}, request.context, {
115132
compileDebug: config( 'env' ) === 'development' ? true : false,
116133
urls: generateStaticUrls( request ),
@@ -126,7 +143,7 @@ function getDefaultContext( request ) {
126143
isFluidWidth: !! config.isEnabled( 'fluid-width' ),
127144
abTestHelper: !! config.isEnabled( 'dev/test-helper' ),
128145
devDocsURL: '/devdocs',
129-
store: createReduxStore()
146+
store: createReduxStore( initialServerState )
130147
} );
131148

132149
context.app = {

server/render/index.js

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import ReactDomServer from 'react-dom/server';
55
import superagent from 'superagent';
66
import Lru from 'lru';
7-
import pick from 'lodash/pick';
7+
import { isEmpty, pick } from 'lodash';
88
import debugFactory from 'debug';
99

1010
/**
@@ -17,9 +17,16 @@ import {
1717
getDocumentHeadMeta,
1818
getDocumentHeadLink
1919
} from 'state/document-head/selectors';
20+
import { reducer } from 'state';
21+
import { SERIALIZE } from 'state/action-types';
22+
import stateCache from 'state-cache';
2023

2124
const debug = debugFactory( 'calypso:server-render' );
22-
const markupCache = new Lru( { max: 3000 } );
25+
const HOUR_IN_MS = 3600000;
26+
const markupCache = new Lru( {
27+
max: 3000,
28+
maxAge: HOUR_IN_MS
29+
} );
2330

2431
function bumpStat( group, name ) {
2532
const statUrl = `http://pixel.wp.com/g.gif?v=wpcom-no-pv&x_${ group }=${ name }&t=${ Math.random() }`;
@@ -35,21 +42,19 @@ function bumpStat( group, name ) {
3542
*
3643
* @param {object} element - React element to be rendered to html
3744
* @param {string} key - (optional) custom key
38-
* @return {object} context object with `renderedLayout` field populated
45+
* @return {string} The rendered Layout
3946
*/
4047
export function render( element, key = JSON.stringify( element ) ) {
4148
try {
4249
const startTime = Date.now();
4350
debug( 'cache access for key', key );
4451

45-
let context = markupCache.get( key );
46-
if ( ! context ) {
52+
let renderedLayout = markupCache.get( key );
53+
if ( ! renderedLayout ) {
4754
bumpStat( 'calypso-ssr', 'loggedout-design-cache-miss' );
4855
debug( 'cache miss for key', key );
49-
const renderedLayout = ReactDomServer.renderToString( element );
50-
context = { renderedLayout };
51-
52-
markupCache.set( key, context );
56+
renderedLayout = ReactDomServer.renderToString( element );
57+
markupCache.set( key, renderedLayout );
5358
}
5459
const rtsTimeMs = Date.now() - startTime;
5560
debug( 'Server render time (ms)', rtsTimeMs );
@@ -59,7 +64,7 @@ export function render( element, key = JSON.stringify( element ) ) {
5964
bumpStat( 'calypso-ssr', 'over-100ms-rendertostring' );
6065
}
6166

62-
return context;
67+
return renderedLayout;
6368
} catch ( ex ) {
6469
if ( config( 'env' ) === 'development' ) {
6570
throw ex;
@@ -76,9 +81,13 @@ export function serverRender( req, res ) {
7681
context.i18nLocaleScript = '//widgets.wp.com/languages/calypso/' + context.lang + '.js';
7782
}
7883

79-
if ( config.isEnabled( 'server-side-rendering' ) && context.layout && ! context.user ) {
80-
const key = context.renderCacheKey || JSON.stringify( context.layout );
81-
Object.assign( context, render( context.layout, key ) );
84+
if ( config.isEnabled( 'server-side-rendering' ) && context.layout && ! context.user && isEmpty( context.query ) ) {
85+
// context.pathname doesn't include querystring, so it's a suitable cache key.
86+
let key = context.pathname;
87+
if ( req.error ) {
88+
key = req.error.message;
89+
}
90+
context.renderedLayout = render( context.layout, key );
8291
}
8392

8493
if ( context.store ) {
@@ -91,7 +100,14 @@ export function serverRender( req, res ) {
91100
reduxSubtrees = reduxSubtrees.concat( [ 'ui', 'themes' ] );
92101
}
93102

103+
// Send state to client
94104
context.initialReduxState = pick( context.store.getState(), reduxSubtrees );
105+
// And cache on the server, too
106+
if ( isEmpty( context.query ) ) {
107+
// Don't cache if we have query params
108+
const serverState = reducer( context.initialReduxState, { type: SERIALIZE } );
109+
stateCache.set( context.pathname, serverState );
110+
}
95111
}
96112

97113
context.head = { title, metas, links };

server/state-cache/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* External Dependencies
3+
*/
4+
import Lru from 'lru';
5+
6+
const HOUR_IN_MS = 3600000;
7+
const stateCache = new Lru( {
8+
max: 500,
9+
maxAge: HOUR_IN_MS
10+
} );
11+
12+
export default stateCache;

0 commit comments

Comments
 (0)