├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── art-of-optimizing-ssr.md └── sample-template.md ├── gulpfile.js ├── lib ├── cache-store.js ├── config.js └── ssr-caching.js ├── package.json └── test ├── .eslintrc ├── farmhash-mock.js ├── spec ├── cache-store.spec.js ├── caching.simple.spec.js ├── caching.template.spec.js ├── hash-key.spec.js └── profiling.spec.js └── src ├── board.jsx ├── greeting.jsx ├── heading.jsx ├── hello.jsx ├── info-card.jsx ├── recursive-divs.jsx ├── render-board.jsx ├── render-greeting.jsx └── render-hello.jsx /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "es2015-loose", 5 | "react" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/gen-lib 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "./node_modules/electrode-archetype-njs-module-dev/config/eslint/.eslintrc-node" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/gitbook,osx,webstorm,node 3 | 4 | ### GitBook ### 5 | # Node rules: 6 | ## Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 7 | .grunt 8 | 9 | ## Dependency directory 10 | ## Commenting this out is preferred by some people, see 11 | ## https://docs.npmjs.com/misc/faq#should-i-check-my-node_modules-folder-into-git 12 | node_modules 13 | 14 | # Book build output 15 | _book 16 | 17 | # eBook build output 18 | *.epub 19 | *.mobi 20 | *.pdf 21 | 22 | 23 | ### OSX ### 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Icon must end with two \r 29 | Icon 30 | 31 | 32 | # Thumbnails 33 | ._* 34 | 35 | # Files that might appear in the root of a volume 36 | .DocumentRevisions-V100 37 | .fseventsd 38 | .Spotlight-V100 39 | .TemporaryItems 40 | .Trashes 41 | .VolumeIcon.icns 42 | 43 | # Directories potentially created on remote AFP share 44 | .AppleDB 45 | .AppleDesktop 46 | Network Trash Folder 47 | Temporary Items 48 | .apdisk 49 | 50 | 51 | ### WebStorm ### 52 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 53 | 54 | *.iml 55 | 56 | ## Directory-based project format: 57 | .idea/ 58 | # if you remove the above rule, at least ignore the following: 59 | 60 | # User-specific stuff: 61 | # .idea/workspace.xml 62 | # .idea/tasks.xml 63 | # .idea/dictionaries 64 | # .idea/shelf 65 | 66 | # Sensitive or high-churn files: 67 | # .idea/dataSources.ids 68 | # .idea/dataSources.xml 69 | # .idea/sqlDataSources.xml 70 | # .idea/dynamic.xml 71 | # .idea/uiDesigner.xml 72 | 73 | # Gradle: 74 | # .idea/gradle.xml 75 | # .idea/libraries 76 | 77 | # Mongo Explorer plugin: 78 | # .idea/mongoSettings.xml 79 | 80 | ## File-based project format: 81 | *.ipr 82 | *.iws 83 | 84 | ## Plugin-specific files: 85 | 86 | # IntelliJ 87 | /out/ 88 | 89 | # mpeltonen/sbt-idea plugin 90 | .idea_modules/ 91 | 92 | # JIRA plugin 93 | atlassian-ide-plugin.xml 94 | 95 | # Crashlytics plugin (for Android Studio and IntelliJ) 96 | com_crashlytics_export_strings.xml 97 | crashlytics.properties 98 | crashlytics-build.properties 99 | fabric.properties 100 | 101 | 102 | ### Node ### 103 | # Logs 104 | logs 105 | *.log 106 | npm-debug.log* 107 | 108 | # Runtime data 109 | pids 110 | *.pid 111 | *.seed 112 | 113 | # Directory for instrumented libs generated by jscoverage/JSCover 114 | lib-cov 115 | 116 | # Coverage directory used by tools like istanbul 117 | coverage 118 | 119 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 120 | .grunt 121 | 122 | # node-waf configuration 123 | .lock-wscript 124 | 125 | # Compiled binary addons (http://nodejs.org/api/addons.html) 126 | build/Release 127 | 128 | # Dependency directory 129 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 130 | node_modules 131 | 132 | # Optional npm cache directory 133 | .npm 134 | 135 | # Optional REPL history 136 | .node_repl_history 137 | 138 | .tmp 139 | 140 | test/gen-lib 141 | 142 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - v6 5 | 6 | brances: 7 | only: 8 | - master 9 | 10 | before_install: 11 | - currentfolder=${PWD##*/} 12 | - if [ "$currentfolder" != 'electrode-react-ssr-caching' ]; then cd .. && eval "mv $currentfolder electrode-react-ssr-caching" && cd electrode-react-ssr-caching; fi 13 | 14 | script: 15 | - npm test 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 WalmartLabs 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electrode-react-ssr-caching [![NPM version][npm-image]][npm-url] [![Build Status][travis-image]][travis-url] [![Dependency Status][daviddm-image]][daviddm-url] 2 | 3 | Support profiling React Server Side Rendering time and component caching to help you speed up SSR. 4 | 5 | # Installing 6 | 7 | ``` 8 | npm i electrode-react-ssr-caching 9 | ``` 10 | 11 | # Usage 12 | 13 | Note that since this module patches React's source code to inject the caching logic, it must be loaded before the React module. 14 | 15 | For example: 16 | 17 | ```js 18 | import SSRCaching from "electrode-react-ssr-caching"; 19 | import React from 'react'; 20 | import ReactDOM from 'react-dom/server'; 21 | ``` 22 | 23 | 24 | ## Profiling 25 | 26 | You can use this module to inspect the time each component took to render. 27 | 28 | ```js 29 | import SSRCaching from "electrode-react-ssr-caching"; 30 | import { renderToString } from "react-dom/server"; 31 | import MyComponent from "mycomponent"; 32 | 33 | // First you should render your component in a loop to prime the JS engine (i.e: V8 for NodeJS) 34 | for( let i = 0; i < 10; i ++ ) { 35 | renderToString(); 36 | } 37 | 38 | SSRCaching.clearProfileData(); 39 | SSRCaching.enableProfiling(); 40 | const html = renderToString(); 41 | SSRCaching.enableProfiling(false); 42 | console.log(JSON.stringify(SSRCaching.profileData, null, 2)); 43 | ``` 44 | 45 | ## Caching 46 | 47 | Once you determined the most expensive components with profiling, you can enable component caching this module provides to speed up SSR performance. 48 | 49 | The basic steps to enabling caching are: 50 | 51 | ```js 52 | import SSRCaching from "electrode-react-ssr-caching"; 53 | 54 | SSRCaching.enableCaching(); 55 | SSRCaching.setCachingConfig(cacheConfig); 56 | ``` 57 | 58 | Where `cacheConfig` contains information on what component to apply caching. See below for details. 59 | 60 | In order for the `enableCaching()` method to work, you'll also need `NODE_ENV` set to `production`, or else it will throw an error. 61 | 62 | ### cacheConfig 63 | 64 | SSR component caching was first demonstrated in [Sasha Aickin's talk]. 65 | 66 | His demo requires each component to provide a function for generating the cache key. 67 | 68 | Here we implemented two cache key generation strategies: `simple` and `template`. 69 | 70 | You are required to pass in the `cacheConfig` to tell this module what component to apply caching. 71 | 72 | For example: 73 | 74 | ```js 75 | const cacheConfig = { 76 | components: { 77 | "Component1": { 78 | strategy: "simple", 79 | enable: true 80 | }, 81 | "Component2": { 82 | strategy: "template", 83 | enable: true 84 | } 85 | } 86 | } 87 | 88 | SSRCaching.setCachingConfig(cacheConfig); 89 | ``` 90 | 91 | ### Caching Strategies 92 | 93 | #### simple 94 | 95 | The `simple` caching strategy is basically doing a `JSON.stringify` on the component's props. You can also specify a callback in `cacheConfig` to return the key. 96 | 97 | For example: 98 | 99 | ```js 100 | const cacheConfig = { 101 | components: { 102 | Component1: { 103 | strategy: "simple", 104 | enable: true, 105 | genCacheKey: (props) => JSON.stringify(props) 106 | } 107 | } 108 | }; 109 | ``` 110 | 111 | This strategy is not very flexible. You need a cache entry for each different props. However it requires very little processing time. 112 | 113 | #### template 114 | 115 | The `template` caching strategy is more complex but flexible. 116 | 117 | The idea is akin to generating logic-less handlebars template from your React components and then use string replace to process the template with different props. 118 | 119 | If you have this component: 120 | 121 | ```js 122 | class Hello extends Component { 123 | render() { 124 | return
Hello, {this.props.name}. {this.props.message}
125 | } 126 | } 127 | ``` 128 | 129 | And you render it with props: 130 | ```js 131 | const props = { name: "Bob", message: "How're you?" } 132 | ``` 133 | 134 | You get back HTML string: 135 | ```html 136 |
Hello, Bob. How're you?
137 | ``` 138 | 139 | Now if you replace values in props with tokens, and you remember that `@0@` refers to `props.name` and `@1@` refers to `props.message`: 140 | ```js 141 | const tokenProps = { name: "@0@", message: "@1@" } 142 | ``` 143 | 144 | You get back HTML string that could be akin to a handlebars template: 145 | ```html 146 |
Hello, @0@. @1@
147 | ``` 148 | 149 | We cache this template html using the tokenized props as cache key. When we need to render the same component with a different props later, we can just lookup the template from cache and use string replace to apply the values: 150 | ```js 151 | cachedTemplateHtml.replace( /@0@/g, props.name ).replace( /@1@/g, props.message ); 152 | ``` 153 | 154 | That's the gist of the template strategy. Of course there are many small details such as handling the encoding of special characters, preserving props that can't be tokenized, avoid tokenizing non-string props, or preserving `data-reactid` and `data-react-checksum`. 155 | 156 | To specify a component to be cached with the `template` strategy: 157 | 158 | ```js 159 | const cacheConfig = { 160 | components: { 161 | Hello: { 162 | strategy: "template", 163 | enable: true, 164 | preserveKeys: [ "key1", "key2" ], 165 | preserveEmptyKeys: [ "key3", "key4" ], 166 | ignoreKeys: [ "key5", "key6" ], 167 | whiteListNonStringKeys: [ "key7", "key8" ] 168 | } 169 | } 170 | }; 171 | ``` 172 | 173 | - `preserveKeys` - List of keys that should not be tokenized. 174 | - `preserveEmptyKeys` - List of keys that should not be tokenized if they are empty string `""` 175 | - `ignoreKeys` - List of keys that should be completely ignored as part of the template cache key. 176 | - `whiteListNonStringKeys` - List of non-string keys that should be tokenized. 177 | 178 | # API 179 | 180 | ### [`enableProfiling(flag)`](#enableprofilingflag) 181 | 182 | Enable profiling according to flag 183 | 184 | - `undefined` or `true` - enable profiling 185 | - `false` - disable profiling 186 | 187 | ### [`enableCaching(flag)`](#enablecachingflag) 188 | 189 | Enable cache according to flag 190 | 191 | - `undefined` or `true` - enable caching 192 | - `false` - disable caching 193 | 194 | ### [`enableCachingDebug(flag)`](#enablecachingdebugflag) 195 | 196 | Enable cache debugging according to flag. 197 | 198 | > Caching must be enabled for this to have any effect. 199 | 200 | - `undefined` or `true` - enable cache debugging 201 | - `false` - disable cache debugging 202 | 203 | ### [`setCachingConfig(config)`](#setcachingconfigconfig) 204 | 205 | Set caching config to `config`. 206 | 207 | ### [`stripUrlProtocol(flag)`](#stripurlprotocolflag) 208 | 209 | Remove `http:` or `https:` from prop values that are URLs according to flag. 210 | 211 | > Caching must be enabled for this to have any effect. 212 | 213 | - `undefined` or `true` - strip URL protocol 214 | - `false` - don't strip 215 | 216 | ### [`shouldHashKeys(flag, [hashFn])`](#shouldhashkeysflaghashfn) 217 | 218 | Set whether the `template` strategy should hash the cache key and use that instead. 219 | 220 | > Caching must be enabled for this to have any effect. 221 | 222 | - `flag` 223 | - `undefined` or `true` - use a hash value of the cache key 224 | - `false` - don't use a hash valueo f the cache key 225 | - `hashFn` - optional, a custom callback to generate the hash from the cache key, which is passed in as a string 226 | - i.e. `function customHashFn(key) { return hash(key); }` 227 | 228 | If no `hashFn` is provided, then [farmhash] is used if it's available, otherwise hashing is turned off. 229 | 230 | ### [`clearProfileData()`](#clearprofiledata) 231 | 232 | Clear profiling data 233 | 234 | ### [`clearCache()`](#clearcache) 235 | 236 | Clear caching data 237 | 238 | ### [`cacheEntries()`](#cacheentries) 239 | 240 | Get total number of cache entries 241 | 242 | ### [`cacheHitReport()`](#cachehitreport) 243 | 244 | Returns an object with information about cache entry hits 245 | 246 | Built with :heart: by [Team Electrode](https://github.com/orgs/electrode-io/people) @WalmartLabs. 247 | 248 | [Sasha Aickin's talk]: https://www.youtube.com/watch?v=PnpfGy7q96U 249 | [farmhash]: https://github.com/google/farmhash 250 | [npm-image]: https://badge.fury.io/js/electrode-react-ssr-caching.svg 251 | [npm-url]: https://npmjs.org/package/electrode-react-ssr-caching 252 | [travis-image]: https://travis-ci.org/electrode-io/electrode-react-ssr-caching.svg?branch=master 253 | [travis-url]: https://travis-ci.org/electrode-io/electrode-react-ssr-caching 254 | [daviddm-image]: https://david-dm.org/electrode-io/electrode-react-ssr-caching.svg?theme=shields.io 255 | [daviddm-url]: https://david-dm.org/electrode-io/electrode-react-ssr-caching 256 | -------------------------------------------------------------------------------- /docs/art-of-optimizing-ssr.md: -------------------------------------------------------------------------------- 1 | # The Art of Optimizing React SSR Performance 2 | 3 | React Server Side Rendering (SSR) enable seamless isomorphic webapps, but SSR is synchronous and CPU bound, so optimizing SSR for isomorphic React app is essential to improving your server's response time. 4 | 5 | There are two chief things you can do to improve your SSR performance: refactoring your component code or component caching. 6 | 7 | If you know your component code well, then you can pick the low hanging fruits and optimize the obvious components. After that, it may become harder to find components to improve, either for refactoring or caching. 8 | 9 | Either way, the next step is to do some detail profiling to find which one to optimize first. 10 | 11 | ## Setup 12 | 13 | To be able to quickly analyze and test your components, it's best to setup some static data that you can use to run the SSR instantly. 14 | 15 | To prepare the data, it's best if you can get real data or craft some sample data that exercise the most parts of you component. 16 | 17 | You can pass your sample data into your component directly, or setup your data model depending on which one you use. 18 | 19 | For example, to pass your data into the component directly as props: 20 | 21 | ```js 22 | const props = { 23 | // comprehensive sample data 24 | }; 25 | renderToString(); 26 | ``` 27 | 28 | If you have async data for your props and you use a resolver or if you use Redux to setup the data, you can manually initialize the data before you render. 29 | 30 | For example, if you use Redux, here is a rough outline, but depending on your specific setup, you may have to create store with middleware etc. 31 | 32 | ```js 33 | // imports for renderToString, createStore, Provider, MyComponent, etc 34 | 35 | function initializeStore(data) { 36 | const store = createStore(); 37 | return Promise.resolve((dispatch) => dispatch(data)).then( () => store ); 38 | } 39 | 40 | const data = { 41 | // comprehensive sample data to initialize redux store 42 | }; 43 | 44 | initializeStore(data) 45 | .then( (store) => renderToString()); 46 | ``` 47 | 48 | ## Profiling 49 | 50 | After you've written profiling code to manually render your component with static data, you can run the rendering quickly as many times as you like. 51 | 52 | The first thing you want to do now is run profiling for a single rendering pass to find how long each individual component take. 53 | 54 | Here is an example to use electrode-react-ssr-caching to profile a component using Redux: 55 | 56 | ```js 57 | const data = { 58 | // sample data to initialize redux store 59 | }; 60 | 61 | // First prime the JS engine 62 | const renderComponent = () => { 63 | return initializeStore(data) 64 | .then( (store) => renderToString()); 65 | } 66 | 67 | let promise = renderComponent(); 68 | 69 | for( let i = 0; i < 10; i ++ ) { 70 | promise = promise.then( renderComponent ); 71 | } 72 | 73 | // Now profile and save the data for a single rendering pass 74 | 75 | promise.then( () => { 76 | SSRCaching.enableProfiling(); 77 | SSRCaching.clearProfileData(); 78 | return renderComponent() 79 | .then( () => { 80 | console.log( SSRCaching.data, null, 2 ); 81 | }); 82 | }); 83 | ``` 84 | 85 | > You should save the profile data to a file rather than log it to the console. 86 | 87 | ## Identifying 88 | 89 | Once you get the JSON data with timing on your components, you can identify what's happening and which components are the most expensive to render. 90 | 91 | Here is a simple example of how the profiling data might look like in YAML format. 92 | 93 | > If you are using Redux you would see Redux's helper components such as `Connect` or `Provider` 94 | 95 | ```yaml 96 | --- 97 | Board: 98 | - 99 | time: 3.652683 100 | Heading: 101 | - 102 | time: 0.379035 103 | InfoCard: 104 | - 105 | time: 1.492614 106 | Hello: 107 | - 108 | time: 0.230108 109 | - 110 | time: 0.30988 111 | Hello: 112 | - 113 | time: 0.095122 114 | - 115 | time: 0.50647 116 | Hello: 117 | - 118 | time: 0.162786 119 | ``` 120 | 121 | The time is in milliseconds. As you can see from the above data, here is what it means: 122 | 123 | - Component `Board` took `3.652683ms` to render and it contain children `Heading` and `InfoCard` 124 | - `Heading` took `0.379035ms` to render 125 | - There are 3 instances of `InfoCard`, each one took slightly different time to render and has `Hello` as child. 126 | 127 | > I didn't prime the JS engine when I collected the above data so you can see the first `InfoCard` instance took the longest to render. 128 | 129 | ## Testing 130 | 131 | ## Inspecting 132 | 133 | 134 | -------------------------------------------------------------------------------- /docs/sample-template.md: -------------------------------------------------------------------------------- 1 | # example template for the template cache strategy: 2 | 3 | ```js 4 | var props = { 5 | foo: { 6 | bar: { 7 | a: [0, 1, 2, 3, 4], 8 | b: "hello" 9 | } 10 | } 11 | }; 12 | 13 | var template = { 14 | foo: { 15 | bar: { 16 | a: [`@'0"@`, `@'1"@`, `@'2"@`, `@'3"@`, `@'4"@`], 17 | b: `@'5"@` 18 | } 19 | } 20 | }; 21 | 22 | var lookup = { 23 | "@0@": "foo.bar.a.0", 24 | "@1@": "foo.bar.a.1", 25 | "@2@": "foo.bar.a.2", 26 | "@3@": "foo.bar.a.3", 27 | "@4@": "foo.bar.a.4", 28 | "@5@": "foo.bar.b" 29 | }; 30 | ``` 31 | 32 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | require("electrode-archetype-njs-module-dev")(); -------------------------------------------------------------------------------- /lib/cache-store.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* eslint-disable */ 3 | function CacheStore(cfg) { 4 | this.cache = {}; 5 | this.size = 0; 6 | this.entries = 0; 7 | this.config = cfg; 8 | } 9 | 10 | CacheStore.prototype.cleanCache = function (minFreeSize) { 11 | const keys = Object.keys(this.cache); 12 | keys.sort((a, b) => this.cache[a].access - this.cache[b].access); 13 | let freed = 0; 14 | for (let i = 0; i < keys.length; i++) { 15 | const key = keys[i]; 16 | const entry = this.cache[key]; 17 | if (freed >= minFreeSize) { 18 | break; 19 | } 20 | delete this.cache[key]; 21 | const freeSize = key.length + entry.html.length; 22 | freed += freeSize; 23 | this.size -= freeSize; 24 | this.entries--; 25 | } 26 | }; 27 | 28 | CacheStore.prototype.newEntry = function (name, key, value) { 29 | const entryKey = `${name}-${key}`; 30 | const size = entryKey.length + value.html.length; 31 | var newSize = this.size + size; 32 | if (newSize > this.config.MAX_CACHE_SIZE) { 33 | const freeSize = Math.max(size, this.config.minFreeCacheSize); 34 | this.cleanCache(Math.min(freeSize, this.config.maxFreeCacheSize)); 35 | newSize = this.size + size; 36 | } 37 | this.cache[entryKey] = value; 38 | value.hits = 0; 39 | value.access = Date.now(); 40 | this.size = newSize; 41 | this.entries++; 42 | }; 43 | 44 | CacheStore.prototype.getEntry = function (name, key) { 45 | const entryKey = `${name}-${key}`; 46 | const x = this.cache[entryKey]; 47 | if (x) { 48 | x.hits++; 49 | x.access = Date.now(); 50 | } 51 | return x; 52 | }; 53 | 54 | module.exports = CacheStore; 55 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint-disable no-magic-numbers */ 4 | 5 | const config = { 6 | enabled: false, 7 | profiling: false, 8 | caching: false, 9 | debug: false, 10 | hashKey: true, 11 | stripUrlProtocol: true, 12 | cacheExpireTime: 15 * 60 * 1000, // 15 min 13 | MAX_CACHE_SIZE: 50 * 1024 * 1024, // 50Meg 14 | minFreeCacheSize: 1024 * 1024, // 1 Meg - min size to free when cache is full 15 | maxFreeCacheSize: 10 * 1024 * 1024 // 10 Meg - max size to free when cache is full 16 | }; 17 | 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /lib/ssr-caching.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint-disable no-magic-numbers */ 4 | 5 | const ReactCompositeComponent = require("react-dom/lib/ReactCompositeComponent"); 6 | const DOMPropertyOperations = require("react-dom/lib/DOMPropertyOperations"); 7 | const assert = require("assert"); 8 | const _ = require("lodash"); 9 | const CacheStore = require("./cache-store"); 10 | const config = require("./config"); 11 | 12 | const noOpHashKeyFn = (key) => key; 13 | let hashKeyFn = noOpHashKeyFn; 14 | 15 | exports.shouldHashKeys = function (flag, fn) { 16 | if (typeof flag === "boolean") { 17 | config.hashKey = flag; 18 | } 19 | 20 | if (config.hashKey) { 21 | if (!fn) { 22 | try { 23 | const FarmHash = require("farmhash"); // eslint-disable-line 24 | hashKeyFn = (s) => FarmHash.hash64(s); 25 | } catch (e) { 26 | console.log("farmhash module not available, turning off hashKey"); // eslint-disable-line 27 | config.hashKey = false; 28 | hashKeyFn = noOpHashKeyFn; 29 | } 30 | } else { 31 | assert(typeof fn === "function", "hashKey function is not a function"); 32 | hashKeyFn = fn; 33 | } 34 | } else { 35 | hashKeyFn = noOpHashKeyFn; 36 | } 37 | 38 | exports.hashKeyFn = hashKeyFn; 39 | }; 40 | 41 | exports.shouldHashKeys(); 42 | 43 | const profileData = {}; 44 | const blackListed = {}; 45 | let cacheComponents = {}; 46 | let cacheStore; 47 | 48 | // 49 | // cache key is generated from props 50 | // generate template from props for cache strategy template 51 | // 52 | // Note: It's hard and tricky to template non-string props. Since the code may 53 | // behave differently depending on a boolean being true/false, or a number with different 54 | // values. Even string props the code could behave differently base on what the value is. 55 | // For example, collection status could be "PUBLISHED", "UNPUBLISHED", etc. 56 | // 57 | // Non-string props could also be stringified differently by the component. 58 | // 59 | // returns { template, lookup, cacheKey } 60 | // 61 | function generateTemplate(props, opts) { 62 | const template = {}; 63 | const lookup = {}; 64 | const path = []; 65 | let index = 0; 66 | const cacheKey = []; 67 | 68 | const gen = (obj, tmpl) => { // eslint-disable-line 69 | const keys = Object.keys(obj); 70 | for (let i = 0; i < keys.length; i++) { 71 | const k = keys[i]; 72 | const v = obj[k]; 73 | if (opts.ignoreKeys.indexOf(k) >= 0) { 74 | tmpl[k] = v; 75 | continue; 76 | } 77 | 78 | cacheKey.push(k); 79 | //cacheKey.push(typeof v); 80 | if (opts.preserveKeys.indexOf(k) >= 0) { 81 | tmpl[k] = v; 82 | cacheKey.push(JSON.stringify(v)); 83 | } else if (typeof v === "function") { 84 | tmpl[k] = v; 85 | } else if (v && typeof v === "object") { 86 | const isArray = Array.isArray(v); 87 | if (isArray) { 88 | tmpl[k] = []; 89 | cacheKey.push(`[${v.length}`); 90 | } else { 91 | tmpl[k] = {}; 92 | } 93 | path.push(k); 94 | gen(v, tmpl[k]); 95 | isArray && cacheKey.push("]"); // eslint-disable-line 96 | path.pop(k); 97 | } else if (typeof v === "string") { 98 | if (!v && opts.preserveEmptyKeys.indexOf(k) >= 0) { 99 | // Sometimes components have logic dependent on strings being empty 100 | // For example: It skips showing the UI to display a message if it's empty 101 | tmpl[k] = v; 102 | } else { 103 | const templateValue = `@'${index}"@`; 104 | if (config.stripUrlProtocol) { 105 | const lv = v.toLowerCase(); 106 | if (lv.startsWith("http://")) { // eslint-disable-line 107 | tmpl[k] = `http://${templateValue}`; 108 | } else if (lv.startsWith("https://")) { 109 | tmpl[k] = `https://${templateValue}`; 110 | } else { 111 | tmpl[k] = templateValue; 112 | } 113 | } else { 114 | tmpl[k] = templateValue; 115 | } 116 | const lookupKey = `@${index}@`; 117 | lookup[lookupKey] = path.concat(k); 118 | cacheKey.push(`:${lookupKey}`); 119 | index++; 120 | } 121 | } else if (v && opts.whiteListNonStringKeys.indexOf(k) >= 0) { 122 | tmpl[k] = `@'${index}"@`; 123 | const lookupKey = `@${index}@`; 124 | cacheKey.push(`:${lookupKey}`); 125 | lookup[lookupKey] = path.concat(k); 126 | index++; 127 | } else { 128 | tmpl[k] = v; 129 | cacheKey.push(JSON.stringify(v)); 130 | } 131 | } 132 | }; 133 | 134 | gen(props, template); 135 | return { 136 | template, lookup, cacheKey: cacheKey.join(",") 137 | }; 138 | } 139 | 140 | const replacements = { 141 | "&": "&", 142 | "<": "<", 143 | ">": ">", 144 | "'": "'", 145 | '"': """ // eslint-disable-line 146 | }; 147 | 148 | ReactCompositeComponent._construct = ReactCompositeComponent.construct; 149 | ReactCompositeComponent._mountComponent = ReactCompositeComponent.mountComponent; 150 | 151 | ReactCompositeComponent.construct = function (element) { 152 | if (config.enabled) { 153 | if (config.profiling) { 154 | this.__p = {time: undefined}; 155 | this.__profileTime = this.__realProfileTime; 156 | } 157 | this._name = element.type && typeof element.type !== "string" && element.type.name; 158 | this.mountComponent = this._name ? this.mountComponentCache : this._mountComponent; 159 | } 160 | return this._construct(element); 161 | }; 162 | 163 | ReactCompositeComponent.__profileTime = function () { 164 | }; 165 | 166 | ReactCompositeComponent.__realProfileTime = function (start) { 167 | const name = this._name; 168 | const d = process.hrtime(start); 169 | const owner = this._currentElement._owner; 170 | 171 | if (owner) { 172 | (owner.__p[name] || (owner.__p[name] = [])).push(this.__p); 173 | } else { 174 | (profileData[name] || (profileData[name] = [])).push(this.__p); 175 | } 176 | 177 | assert(this.__p.time === undefined); 178 | 179 | this.__p.time = d[0] * 1000.0 + d[1] / 1000000.0; 180 | }; 181 | 182 | const restoreRealProps = (r, lookup, realProps) => { 183 | return r.replace(/(\@\'|\@')([0-9]+)(\"\@|\"\@)/g, (m, a, b) => { 184 | let v = _.get(realProps, lookup[`@${b}@`]); 185 | if (typeof v === "string") { 186 | if (config.stripUrlProtocol) { 187 | const lv = v.toLowerCase(); 188 | if (lv.startsWith("http://")) { 189 | v = v.substr(7); 190 | } else if (lv.startsWith("https://")) { 191 | v = v.substr(8); 192 | } 193 | } 194 | if (a === `@'`) { 195 | return v; 196 | } 197 | return v.replace(/([&<'">])/g, (m2, c2) => { 198 | return replacements[c2]; 199 | }); 200 | } else { 201 | return v; 202 | } 203 | }); 204 | }; 205 | 206 | const MARKUP_FOR_ROOT = DOMPropertyOperations.createMarkupForRoot(); 207 | 208 | const addRootMarkup = (r) => { 209 | return r.replace(/(<[^ >]*)([ >])/, (m, a, b) => `${a} ${MARKUP_FOR_ROOT}${b}`); 210 | }; 211 | 212 | const REACT_ID_REPLACE_REGEX = new RegExp(`(data-reactid="|react-text: |react-empty: )[0-9]*`, "g"); 213 | 214 | const updateReactId = (r, hostContainerInfo) => { // eslint-disable-line 215 | let id = hostContainerInfo._idCounter; 216 | r = r.replace(REACT_ID_REPLACE_REGEX, (m, a) => `${a}${id++}`); 217 | hostContainerInfo._idCounter = id; 218 | return r; 219 | }; 220 | 221 | const templatePostProcess = (r, lookup, realProps, transaction, hostContainerInfo) => { // eslint-disable-line 222 | r = restoreRealProps(r, lookup, realProps); 223 | return transaction.renderToStaticMarkup ? r : updateReactId(r, hostContainerInfo); 224 | }; 225 | 226 | ReactCompositeComponent.mountComponentCache = function mountComponentCache(transaction, hostParent, hostContainerInfo, context, parentDebugID) { // eslint-disable-line 227 | 228 | let template; 229 | let cached; 230 | let key; 231 | let cacheType; 232 | let opts; 233 | 234 | const currentElement = Object.assign({}, this._currentElement); 235 | this._currentElement = currentElement; 236 | const saveProps = currentElement.props; 237 | const name = this._name; 238 | const startTime = config.profiling && process.hrtime(); 239 | 240 | // if props has children then can't cache 241 | // if owner is already caching, then no need to cache 242 | const canCache = (opts = cacheComponents[name]) && opts.enable && !(transaction._cached || 243 | _.isEmpty(saveProps) || typeof saveProps.children === "object"); 244 | 245 | const doCache = config.caching && canCache; 246 | 247 | if (doCache) { 248 | const strategy = opts.strategy; 249 | if (strategy === "simple") { 250 | cacheType = "cache"; 251 | template = { 252 | cacheKey: opts.genCacheKey ? opts.genCacheKey(saveProps) : JSON.stringify(saveProps) 253 | }; 254 | key = hashKeyFn(template.cacheKey); 255 | cached = cacheStore.getEntry(name, key); 256 | if (cached) { 257 | this.__profileTime(startTime); 258 | let r = cached.html; 259 | if (!transaction.renderToStaticMarkup) { 260 | r = updateReactId(cached.html, hostContainerInfo); 261 | } 262 | if (!hostParent) { 263 | r = addRootMarkup(r); 264 | } 265 | return config.debug ? 266 | `${r}` : r; 267 | } 268 | } else if (strategy === "template") { 269 | cacheType = "cache"; 270 | template = generateTemplate(saveProps, opts); 271 | key = hashKeyFn(template.cacheKey); 272 | cached = cacheStore.getEntry(name, key); 273 | if (cached) { 274 | let r = templatePostProcess(cached.html, template.lookup, 275 | saveProps, transaction, hostContainerInfo); 276 | if (!hostParent) { 277 | r = addRootMarkup(r); 278 | } 279 | this.__profileTime(startTime); 280 | return config.debug ? `${r}` : r; 281 | } 282 | } else { 283 | throw new Error(`Unknown caching strategy ${strategy} for component ${name}`); 284 | } 285 | } else if (transaction._cached) { 286 | cacheType = "byParent"; 287 | } else { 288 | cacheType = "NONE"; 289 | } 290 | 291 | const saveId = hostContainerInfo._idCounter; 292 | 293 | if (template) { 294 | if (template.template) { 295 | currentElement.props = template.template; 296 | } 297 | hostContainerInfo._idCounter = 1; 298 | transaction._cached = this._name; 299 | } 300 | 301 | let r = this._mountComponent(transaction, hostParent, hostContainerInfo, context, parentDebugID); 302 | 303 | if (template) { 304 | hostContainerInfo._idCounter = saveId; 305 | currentElement.props = saveProps; 306 | cacheStore.newEntry(name, key, {html: r.replace(` ${MARKUP_FOR_ROOT}`, "")}); 307 | 308 | if (template.lookup) { 309 | r = templatePostProcess(r, template.lookup, 310 | saveProps, transaction, hostContainerInfo); 311 | } else if (!transaction.renderToStaticMarkup) { 312 | r = updateReactId(r, hostContainerInfo); 313 | } 314 | transaction._cached = undefined; 315 | } 316 | 317 | this.__profileTime(startTime); 318 | 319 | if (config.caching && config.debug) { 320 | return `${r}`; 321 | } else { 322 | return r; 323 | } 324 | }; 325 | 326 | exports.enableProfiling = function (flag) { 327 | config.profiling = flag === undefined || !!flag; 328 | config.enabled = config.profiling || config.caching; 329 | }; 330 | 331 | exports.enableCaching = function (flag) { 332 | config.caching = flag === undefined || !!flag; 333 | config.enabled = config.profiling || config.caching; 334 | }; 335 | 336 | exports.enableCachingDebug = function (flag) { 337 | config.debug = flag === undefined || !!flag; 338 | }; 339 | 340 | exports.stripUrlProtocol = function (flag) { 341 | config.stripUrlProtocol = flag; 342 | }; 343 | 344 | exports.profileData = profileData; 345 | 346 | exports.clearProfileData = function () { 347 | Object.keys(profileData).forEach((k) => { 348 | delete profileData[k]; 349 | }); 350 | }; 351 | 352 | exports.clearCache = function () { 353 | exports.cacheStore = cacheStore = new CacheStore(config); 354 | }; 355 | 356 | exports.cacheSize = function () { 357 | return cacheStore.size; 358 | }; 359 | 360 | exports.cacheEntries = function () { 361 | return cacheStore.entries; 362 | }; 363 | 364 | exports.cacheHitReport = function () { 365 | const hitReport = {}; 366 | Object.keys(cacheStore.cache).forEach((key) => { 367 | const entry = cacheStore.cache[key]; 368 | hitReport[key] = { 369 | hits: entry.hits 370 | }; 371 | }); 372 | return hitReport; 373 | }; 374 | 375 | exports.config = config; 376 | 377 | exports.setCachingConfig = function (cfg) { 378 | cacheComponents = cfg.components || {}; 379 | Object.keys(cacheComponents).forEach((k) => { 380 | _.defaults(cacheComponents[k], { 381 | preserveKeys: [], 382 | preserveEmptyKeys: [], 383 | ignoreKeys: [], 384 | whiteListNonStringKeys: [] 385 | }); 386 | }); 387 | }; 388 | 389 | exports.blackListed = blackListed; 390 | 391 | exports.clearCache(); 392 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electrode-react-ssr-caching", 3 | "version": "0.1.6", 4 | "description": "Optimize React SSR with profiling and component caching", 5 | "main": "lib/ssr-caching.js", 6 | "scripts": { 7 | "test": "npm run build-test && gulp check", 8 | "build-test": "rm -f test/gen-lib/* && babel test/src -d test/gen-lib", 9 | "coverage": "npm run build-test && gulp test-cov" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git@github.com:electrode-io/electrode-react-ssr-caching.git" 14 | }, 15 | "keywords": [], 16 | "author": "Joel Chen ", 17 | "license": "Apache-2.0", 18 | "optionalDependencies": { 19 | "farmhash": "^1.1.1" 20 | }, 21 | "peerDependencies": { 22 | "react": "^15.3.0" 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "^6.10.1", 26 | "babel-core": "^6.10.4", 27 | "babel-preset-es2015": "^6.9.0", 28 | "babel-preset-es2015-loose": "^7.0.0", 29 | "babel-preset-react": "^6.11.1", 30 | "babel-register": "^6.9.0", 31 | "electrode-archetype-njs-module-dev": "^1.0.1", 32 | "gulp": "^3.9.1", 33 | "mock-require": "^1.3.0", 34 | "react": "^15.3.0", 35 | "react-dom": "^15.3.0", 36 | "react-redux": "^4.4.5", 37 | "redux": "^3.5.2" 38 | }, 39 | "dependencies": { 40 | "lodash": "^4.13.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "../node_modules/electrode-archetype-njs-module-dev/config/eslint/.eslintrc-test" 4 | -------------------------------------------------------------------------------- /test/farmhash-mock.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const mock = require("mock-require"); 4 | const crypto = require("crypto"); 5 | 6 | mock("farmhash", { 7 | hash64: function (s) { 8 | const hash = crypto.createHmac("md5", "") 9 | .update(s) 10 | .digest("hex"); 11 | 12 | return hash; 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /test/spec/cache-store.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // test cache store feature 4 | 5 | const CacheStore = require("../../lib/cache-store"); 6 | 7 | describe("CacheStore", function () { 8 | it("should cache entry", function () { 9 | const cacheStore = new CacheStore({ 10 | cacheExpireTime: 200, 11 | MAX_CACHE_SIZE: 1024, 12 | minFreeCacheSize: 200, 13 | maxFreeCacheSize: 400 14 | }); 15 | expect(cacheStore.getEntry("test", "1")).to.equal(undefined); 16 | cacheStore.newEntry("test", "1", {html: "hello"}); 17 | expect(cacheStore.getEntry("test", "1")).to.be.ok; 18 | expect(cacheStore.getEntry("test", "1").html).to.equal("hello"); 19 | expect(cacheStore.getEntry("test", "1").hits).to.equal(3); 20 | }); 21 | 22 | it("should free up cache", function (done) { 23 | const cacheStore = new CacheStore({ 24 | cacheExpireTime: 100, 25 | MAX_CACHE_SIZE: 85, 26 | minFreeCacheSize: 20, 27 | maxFreeCacheSize: 40 28 | }); 29 | cacheStore.newEntry("test", "1", {html: "hello1"}); 30 | cacheStore.newEntry("test", "2", {html: "hello2"}); 31 | cacheStore.newEntry("test", "3", {html: "hello3"}); 32 | cacheStore.newEntry("test", "4", {html: "hello4"}); 33 | cacheStore.newEntry("test", "5", {html: "hello5"}); 34 | cacheStore.newEntry("test", "6", {html: "hello6"}); 35 | cacheStore.newEntry("test", "7", {html: "hello7"}); 36 | setTimeout(() => { 37 | cacheStore.getEntry("test", "5"); 38 | cacheStore.newEntry("foobar", "1", {html: "blahblahblahblahblah"}); 39 | expect(Object.keys(cacheStore.cache)).includes("test-4", "test-6", "test-5", "foobar-1"); 40 | cacheStore.newEntry("foobar", "2", {html: "blahblahblahblahblah"}); 41 | expect(Object.keys(cacheStore.cache)).to.deep.equal(["test-5", "foobar-1", "foobar-2"]); 42 | done(); 43 | }, 90); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/spec/caching.simple.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // test simple caching feature 4 | 5 | require("../farmhash-mock"); 6 | const SSRCaching = require("../.."); 7 | const renderGreeting = require("../gen-lib/render-greeting").default; 8 | const chai = require("chai"); 9 | const expect = chai.expect; 10 | process.env.NODE_ENV = "production"; 11 | 12 | describe("SSRCaching simple caching", function () { 13 | afterEach(() => { 14 | SSRCaching.setCachingConfig({}); 15 | SSRCaching.clearCache(); 16 | SSRCaching.clearProfileData(); 17 | }); 18 | 19 | const verifyRenderResults = (r1, r2, r3) => { 20 | expect(r1).to.equal(r2); 21 | expect(r1).to.equal(r3); 22 | expect(r2).to.equal(r3); 23 | }; 24 | 25 | // 26 | // test simple strategy with user provided function to generate cache key 27 | // 28 | it("should cache component with simple strategy", function () { 29 | const message = "how're you?"; 30 | 31 | let start = Date.now(); 32 | const r1 = renderGreeting("test", message); 33 | const r1Time = Date.now() - start; 34 | 35 | SSRCaching.enableCaching(); 36 | SSRCaching.setCachingConfig({ 37 | components: { 38 | "Hello": { 39 | strategy: "simple", 40 | enable: true, 41 | genCacheKey: () => "key-simple" 42 | } 43 | } 44 | }); 45 | 46 | // should add an entry to cache with key-simple 47 | 48 | SSRCaching.shouldHashKeys(false); 49 | renderGreeting("test", message); 50 | expect(SSRCaching.cacheStore.getEntry("Hello", "key-simple").hits).to.equal(1); 51 | 52 | // should add an entry to cache with hashed key from key-simple 53 | 54 | SSRCaching.shouldHashKeys(true); 55 | start = Date.now(); 56 | const r2 = renderGreeting("test", message); 57 | const r2Time = Date.now() - start; 58 | const entry = SSRCaching.cacheStore.getEntry("Hello", "500034349202595839ffe2cb6f83665b"); 59 | expect(entry.hits).to.equal(1); 60 | 61 | // now render should use result from cache 62 | 63 | start = Date.now(); 64 | const r3 = renderGreeting("test", message); 65 | const r3Time = Date.now() - start; 66 | 67 | console.log(`rendering time r1 ${r1Time}ms r2 ${r2Time} r3 (cached) ${r3Time}`); 68 | expect(r3Time).below(r1Time); 69 | expect(r3Time).below(r2Time); 70 | 71 | expect(entry.hits).to.equal(2); 72 | verifyRenderResults(r1, r2, r3); 73 | }); 74 | 75 | // 76 | // test simple strategy with JSON.stringify on props to generate cache key 77 | // 78 | it("should cache component with simple strategy and stringify", function () { 79 | const message = "good morning"; 80 | 81 | SSRCaching.enableProfiling(true); 82 | const r1 = renderGreeting("test", message); 83 | const data = SSRCaching.profileData; 84 | expect(data.Greeting[0].Hello[0].time).to.be.above(0); 85 | 86 | SSRCaching.enableProfiling(false); 87 | SSRCaching.clearProfileData(); 88 | expect(data).to.deep.equal({}); 89 | 90 | SSRCaching.enableCaching(); 91 | SSRCaching.setCachingConfig({ 92 | components: { 93 | "Hello": { 94 | strategy: "simple", 95 | enable: true 96 | } 97 | } 98 | }); 99 | 100 | // should add an entry to cache with stringified props as cache key 101 | 102 | SSRCaching.shouldHashKeys(false); 103 | renderGreeting("test", message); 104 | expect(SSRCaching.cacheStore.getEntry("Hello", JSON.stringify({name: "test", message})).hits).to.equal(1); 105 | 106 | // should add an entry to cache with hashed value of key 107 | 108 | SSRCaching.shouldHashKeys(true); 109 | const r2 = renderGreeting("test", message); 110 | const entry = SSRCaching.cacheStore.getEntry("Hello", "6a86523415a68c4c7580fe6db324923c"); 111 | expect(entry.hits).to.equal(1); 112 | 113 | // now render should use result from cache 114 | 115 | SSRCaching.enableProfiling(true); 116 | const r3 = renderGreeting("test", message); 117 | expect(data.Greeting[0].Hello[0].time).to.be.above(0); 118 | expect(entry.hits).to.equal(2); 119 | verifyRenderResults(r1, r2, r3); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/spec/caching.template.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // test template caching feature 4 | 5 | require("../farmhash-mock"); 6 | const SSRCaching = require("../.."); 7 | const renderGreeting = require("../gen-lib/render-greeting").default; 8 | const renderBoard = require("../gen-lib/render-board").default; 9 | const renderHello = require("../gen-lib/render-hello").default; 10 | const chai = require("chai"); 11 | const expect = chai.expect; 12 | process.env.NODE_ENV = "production"; 13 | 14 | describe("SSRCaching template caching", function () { 15 | 16 | this.timeout(10000); 17 | 18 | beforeEach(() => { 19 | SSRCaching.setCachingConfig({}); 20 | SSRCaching.clearCache(); 21 | SSRCaching.clearProfileData(); 22 | SSRCaching.enableCaching(false); 23 | SSRCaching.enableProfiling(false); 24 | }); 25 | 26 | function removeReactChecksum(html) { 27 | return html.replace(/data-react-checksum\=\"[^\"]+\"/g, "").replace(/ *>/g, ">"); 28 | } 29 | 30 | const verifyRenderResults = (r1, r2, r3, cleanUp) => { // eslint-disable-line 31 | if (cleanUp) { 32 | r1 = removeReactChecksum(r1); 33 | r2 = removeReactChecksum(r2); 34 | r3 = removeReactChecksum(r3); 35 | } 36 | expect(r1).to.equal(r2); 37 | expect(r1).to.equal(r3); 38 | expect(r2).to.equal(r3); 39 | }; 40 | 41 | // 42 | // test simple strategy with user provided function to generate cache key 43 | // 44 | it("should cache component with template strategy", function () { 45 | const message = "good morning"; 46 | 47 | // save render Hello with caching off 48 | const rHello1 = renderHello("test", message); // eslint-disable-line 49 | 50 | // save render Greeting with caching off 51 | const r1 = renderGreeting("test", message); 52 | 53 | // Enable caching and test 54 | 55 | SSRCaching.enableCaching(); 56 | SSRCaching.setCachingConfig({ 57 | components: { 58 | "Hello": { 59 | strategy: "template", 60 | enable: true 61 | } 62 | } 63 | }); 64 | 65 | // should add an entry to cache with template key 66 | 67 | SSRCaching.stripUrlProtocol(true); 68 | SSRCaching.shouldHashKeys(false); 69 | 70 | // first just render Hello by itself to create a cache with diff react-id's 71 | 72 | renderHello("test", message); // eslint-disable-line 73 | const key1 = Object.keys(SSRCaching.cacheStore.cache)[0]; 74 | const keyTmpl = key1.substr(6); 75 | const entry1 = SSRCaching.cacheStore.getEntry("Hello", keyTmpl); 76 | expect(entry1.hits).to.equal(1); 77 | 78 | // render hello again and verify cache 79 | 80 | const rHello2 = renderHello("test", message); 81 | expect(rHello1).to.equal(rHello2); 82 | expect(entry1.hits).to.equal(2); 83 | 84 | // render Greeting that has Hello inside and verify 85 | 86 | const rX = renderGreeting("test", message); 87 | expect(entry1.hits).to.equal(3); 88 | expect(r1).to.equal(rX); 89 | 90 | // should add an entry to cache with hashed key from template key 91 | 92 | SSRCaching.shouldHashKeys(true); 93 | const hashKey = SSRCaching.hashKeyFn(keyTmpl); 94 | const r2 = renderGreeting("test", message); 95 | const entry = SSRCaching.cacheStore.getEntry("Hello", hashKey); 96 | expect(entry.hits).to.equal(1); 97 | 98 | // now render should use result from cache 99 | 100 | const r3 = renderGreeting("test", message); 101 | expect(entry.hits).to.equal(2); 102 | expect(r2).includes(message); 103 | verifyRenderResults(r1, r2, r3); 104 | 105 | const hitReport = SSRCaching.cacheHitReport(); 106 | Object.keys(hitReport).forEach((key) => { 107 | console.log(`Cache Entry ${key} Hits ${hitReport[key].hits}`); // eslint-disable-line 108 | }); 109 | 110 | expect(SSRCaching.cacheEntries()).to.equal(2); 111 | expect(SSRCaching.cacheSize()).to.be.above(0); 112 | }); 113 | 114 | const users = [ 115 | { 116 | name: "Joel", 117 | message: "good morning", 118 | hasAvatar: true, 119 | quote: "blah", 120 | preserve: "preserve", 121 | empty: "", 122 | ignore: "ignore-me", 123 | urls: [ 124 | "http://zzzzl.com/xchen11", 125 | "https://github.com/jchip" 126 | ], 127 | data: { 128 | location: "SR", 129 | role: "dev", 130 | distance: 40 131 | }, 132 | random: 1, 133 | f: () => 0 134 | }, 135 | { 136 | name: "Alex", 137 | message: "how're you?", 138 | hasAvatar: false, 139 | quote: "let's do this", 140 | preserve: "preserve", 141 | urls: [ 142 | "http://zzzzl.com/ag", 143 | "https://github.com/alex" 144 | ], 145 | data: { 146 | location: "SJ", 147 | role: "dir", 148 | distance: 20 149 | }, 150 | random: 2, 151 | f: () => 0 152 | }, 153 | { 154 | name: "Arpan", 155 | message: "how's your kitchen?", 156 | hasAvatar: true, 157 | quote: "what's up?", 158 | preserve: "preserve", 159 | urls: [ 160 | "http://zzzzl.com/aa", 161 | "https://github.com/arpan" 162 | ], 163 | data: { 164 | location: "CV", 165 | role: "dev", 166 | distance: 30 167 | }, 168 | random: 3, 169 | f: () => 0 170 | } 171 | ]; 172 | 173 | const cacheConfig = { 174 | components: { 175 | "Heading": { 176 | strategy: "simple", 177 | enable: true 178 | }, 179 | "Hello": { 180 | strategy: "simple", 181 | enable: true 182 | }, 183 | "Board": { 184 | strategy: "template", 185 | enable: false 186 | }, 187 | "InfoCard": { 188 | strategy: "template", 189 | enable: true, 190 | preserveKeys: ["preserve"], 191 | preserveEmptyKeys: ["empty"], 192 | ignoreKeys: ["ignore"], 193 | whiteListNonStringKeys: ["random", "distance"] 194 | } 195 | } 196 | }; 197 | 198 | const verifyAndRemoveUrlProtocol = (r) => { 199 | expect(r).includes("http://"); 200 | expect(r).includes("https://"); 201 | r = r.replace(/http:/g, ""); 202 | r = r.replace(/https:/g, ""); 203 | return r; 204 | }; 205 | 206 | const testTemplate = (stripUrlProtocol, hashKeys, profiling) => { 207 | SSRCaching.enableProfiling(profiling); 208 | 209 | let start = Date.now(); 210 | const r1 = renderBoard(users); 211 | const r1Time = Date.now() - start; 212 | 213 | if (profiling) { 214 | const data = SSRCaching.profileData; 215 | expect(data.Board[0].InfoCard[0].time).to.be.above(0); 216 | SSRCaching.clearProfileData(); 217 | SSRCaching.enableProfiling(false); 218 | } 219 | 220 | SSRCaching.enableCaching(); 221 | SSRCaching.setCachingConfig(cacheConfig); 222 | SSRCaching.shouldHashKeys(hashKeys); 223 | SSRCaching.stripUrlProtocol(stripUrlProtocol); 224 | 225 | start = Date.now(); 226 | let r2 = renderBoard(users); 227 | const r2Time = Date.now() - start; 228 | 229 | const cache = SSRCaching.cacheStore.cache; 230 | const keys = Object.keys(cache); 231 | keys.forEach((x) => { 232 | expect(cache[x].hits).to.equal(0); 233 | }); 234 | 235 | SSRCaching.enableProfiling(profiling); 236 | 237 | start = Date.now(); 238 | let r3 = renderBoard(users); 239 | const r3Time = Date.now() - start; 240 | 241 | if (profiling) { 242 | const data = SSRCaching.profileData; 243 | expect(data.Board[0].InfoCard[0].time).to.be.above(0); 244 | SSRCaching.clearProfileData(); 245 | } 246 | 247 | console.log(`rendering time r1 ${r1Time}ms r2 ${r2Time}ms r3 (cached) ${r3Time}ms`); 248 | expect(r3Time).below(r1Time); 249 | expect(r3Time).below(r2Time); 250 | 251 | expect(Object.keys(cache)).to.deep.equal(keys); 252 | 253 | keys.forEach((x) => { 254 | expect(cache[x].hits).to.equal(1); 255 | if (!hashKeys) { 256 | expect(x).not.includes("ignore"); 257 | } 258 | }); 259 | 260 | if (!stripUrlProtocol) { 261 | r2 = verifyAndRemoveUrlProtocol(r2); 262 | r3 = verifyAndRemoveUrlProtocol(r3); 263 | } 264 | 265 | verifyRenderResults(r1, r2, r3, !stripUrlProtocol); 266 | }; 267 | 268 | it("should handle other scenarios for template cache", function () { 269 | testTemplate(false, false, false); 270 | }); 271 | 272 | it("should handle template and strip url protocol", function () { 273 | testTemplate(true, true, true); 274 | }); 275 | 276 | it("should support debug caching", function () { 277 | renderBoard(users); 278 | SSRCaching.enableCaching(); 279 | SSRCaching.enableCachingDebug(); 280 | expect(SSRCaching.config.debug).to.equal(true); 281 | SSRCaching.enableCachingDebug(false); 282 | expect(SSRCaching.config.debug).to.equal(false); 283 | SSRCaching.enableCachingDebug(true); 284 | expect(SSRCaching.config.debug).to.equal(true); 285 | SSRCaching.setCachingConfig(cacheConfig); 286 | SSRCaching.shouldHashKeys(false); 287 | const r2 = renderBoard(users); 288 | expect(r2).includes("