├── MIT-LICENSE ├── README.md ├── demo.gif └── snapback_cache.js /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Highrise HQ LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Snapback Cache 2 | =========== 3 | 4 |

@highrise Nice new feature with snapback. I'm becoming a big fan of Highrise.

— Echo Design Group (@EchoDesignGrp) March 24, 2015
5 | 6 | 7 | Many apps today have some concept of an infinite scrolling feed: Facebook, Twitter, LinkedIn and many more. Almost all of them suffer from the same problem. If you click on something in the feed that brings you to a new page, when you hit the back button or try to return to that original feed, your place is lost. All the scrolling is gone. 8 | 9 | At [**Highrise**](http://highrisehq.com) we had that same problem. So this is the library we use to fix that. We call it our Snapback Cache, and it's made a big improvement to how people can use infinite scroll in our app and still get a lot of work done without losing their place. 10 | 11 | ![](https://github.com/highrisehq/snapback_cache/blob/master/demo.gif?raw=true) 12 | 13 | Another great thing about this is it operates on the URL, so you can have multiple infinite scrolling feeds to cache. At Highrise we have a "main activity" and then activities for a Contact, etc. They each get their separate cache. To keep a manageable memory footprint for your browser, we keep 10 caches as a maximum. 14 | 15 |
16 | 17 | ## The basics of how it works 18 | 19 | Using this small javascript library, you hook it up to the click events on things in your infinite scrolling feed. For example: 20 | 21 | ```javascript 22 | var snapbackCache = SnapbackCache({ 23 | bodySelector: "#recordings" 24 | }); 25 | 26 | jQuery(document).on("click", "body#recordings a", function (e) { 27 | snapbackCache.cachePage(); 28 | }); 29 | ``` 30 | 31 | Now when people click the links inside our "recordings" container, the stuff inside the current recordings container is cached locally using the browser's session storage. 32 | 33 | Then the javascript library watches the load event of any pages being browsed. If the library sees that that browser's URL is a url we've already cached, and it's not "too old" (15 minutes), we replace the contents of our container (`#recordings` in our example) with the cached version, and scroll the browser to the place where it had been cached. 34 | 35 | This sounds easy, but there are certain things we bumped into that the library also helps with. Things like disabling autofocus events that mess up scrolling and making sure things in the cache can actually be more granularly ignored or even refreshed. 36 | 37 | 38 | ## Syntax and how to use it 39 | 40 | ```javascript 41 | var snapbackCache = SnapbackCache({ 42 | options 43 | }); 44 | ``` 45 | 46 | Here are some example options: 47 | 48 | ```javascript 49 | var snapbackCache = SnapbackCache({ 50 | bodySelector: "mandatory selector of your infinite feed", 51 | finish: function () { 52 | optional method of something that needs to finish on your page before caching the page 53 | }, 54 | removeAutofocus: function () { 55 | optional method to kill autofocusing which screws with scrolling the page 56 | }, 57 | refreshItems: function (dirtyThings) { 58 | optional method to fetch fresh bits from your server you want to replace in the cache 59 | }, 60 | nextPageOffset: function () { 61 | optional method to fetch the current page your scrolled to. this is so you can track what page you should scroll next. see the page-cache:loaded event. 62 | } 63 | }); 64 | ``` 65 | 66 | **bodySelector** is mandatory. It tells us what on the page you want to cache. 67 | 68 | **finish** is a function of things that you'd like to happen before the page is cached to get the page to get cleaned up. For example, we already try to get jQuery animations to finish, but if there's anything else on the page that might be animated or dynamically changing when someone is trying to navigate your site, you probably don't want those "transitional" things cached. In our case we have a search bar that we want cleared up before things are cached. 69 | 70 | **removeAutofocus** is a function that removes any auto focus behavior from your page. autoFocus events can mess with the browsers ability to scroll to the right place. So we want to nip that in this function. In our case we have multiple autofocus things going on, so we clear all that up. 71 | 72 | **refreshItems** is a function to help refresh anything that might have gone stale from the cache. You can use that in conjunction with a method available on snapbackCache called `markDirty`. 73 | 74 | So in our case, we cache a note or comment or email in our feed. But if someone at some point edits/deletes one of those notes, comments or emails, we have javascript call 75 | 76 | ```javascript 77 | snapbackCache.markDirty(id_of_dirty_thing); 78 | ``` 79 | 80 | Then when the snapbackCache replaces the cached contents it's saving for us, it makes sure to call the `refreshItems` function you specify along with an array of "dirty items" you can do something with. In our case, we take all those dirty ids, and issue an ajax call that does all the work to refresh bits of the cached page. 81 | 82 | **nextPageOffset** is a function that the Snapback cache can use to figure out what "page" your user is on. We take that page and store it along the cached contents of the page. That way when the cached page is restored you have the page number the user was on and get pick up infinite paging at the appropriate place. See the `page-cache:loaded` event below to do that. 83 | 84 | 85 | ## Events 86 | 87 | There are a couple of events we send out that are useful. 88 | 89 | **snapback-cache:cached** is an event emitted as soon as the contents of the page have been cached into session storage 90 | 91 | **snapback-cache:loaded** is an event emitted as soon as the contents of the page have been replaced. We use this at Highrise to set the appropriate offset for our infinite scrolling: 92 | 93 | ```javascript 94 | jQuery("#recordings").on("snapback-cache:loaded", function(e, cachedPage) { 95 | // sets the pager to page from the appropriate place 96 | EndlessPage.offset = cachedPage.nextPageOffset 97 | }); 98 | ``` 99 | 100 | `nextPageOffset` was calculated because we had setup a "nextPageOffset" function on the page cache. 101 | 102 | 103 | Installation 104 | ------------ 105 | 106 | 1) Add the `snapback_cache.js` to your javascript stack. 107 | 108 | 2) Add a cache variable with the options set: 109 | 110 | ```javascript 111 | var snapbackCache = SnapbackCache({ 112 | bodySelector: "#recordings", 113 | }); 114 | ``` 115 | 116 | 3) Call `snapbackCache.cacheCurrentPage()` whenever you need to, and magically when people return to that url, the cache will do the rest. 117 | 118 | 119 | Feedback 120 | -------- 121 | [Source code available on Github](https://github.com/highrisehq/snapback_cache). Feedback and pull requests are greatly appreciated. Let me know how we can improve this. 122 | 123 | Credit 124 | -------- 125 | A ton of thanks to everyone at Highrise for helping get this into our stack. Especially [Jon Phenow](https://github.com/jphenow), [Grant Blakeman](https://github.com/gblakeman) and [Michael Dwan](https://github.com/michaeldwan) for the edits and help getting it open sourced. 126 | 127 | 128 | P.S. 129 | --------------- 130 | You should [**follow us on Twitter: here**](http://twitter.com/highrise), or see how we can help you with contact management using [**Highrise**](http://highrisehq.com)  —  a handy tool to help you remove anxiety around tracking who to follow up with and what to do next. 131 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basecamp/snapback_cache/f552f44c37b139968c46875c04b73eefce50fe82/demo.gif -------------------------------------------------------------------------------- /snapback_cache.js: -------------------------------------------------------------------------------- 1 | // Example Usage: 2 | // var pageCache = SnapbackCache({ 3 | // bodySelector: "#recordings", 4 | // }) 5 | // 6 | // pageCache.markDirty("comment/1") 7 | // 8 | // Required options: 9 | // * bodySelector: Element to be cached and position saved 10 | // Optional options: 11 | // * finish Pass function to get things on your page to "finish" that you don't want to cache in an inbetween state. 12 | // By default we finish jQuery Animations. 13 | // * refreshItems You may mark items dirty in your DOM as things are edited but might still be in the 14 | // page cache, this is a callback for refreshing those dirty items. Using above example 15 | // refreshItems function would be passed arguement ["comment/1"] 16 | // * removeAutofocus Pass function to remove items that cause your page to autofocusing. Autofocus behavior can screw with 17 | // setting your scroll position. 18 | // 19 | // Events: 20 | // * snapback-cache:cached Triggered when a cache has been set. The cachedPage object is returned with 21 | // the event (triggered on bodySelector) 22 | // * snapback-cache:loaded Triggered when a cache has been loaded. The cachedPage object is returned with 23 | // the event (triggered on bodySelector) 24 | var SnapbackCache = (function(options) { 25 | var options = options || {} 26 | 27 | var SessionStorageHash = (function() { 28 | var set = function(namespace, key, item){ 29 | var storageHash = sessionStorage.getItem(namespace); 30 | if (!storageHash) { 31 | storageHash = {} 32 | } else { 33 | storageHash = JSON.parse(storageHash) 34 | } 35 | 36 | if (item) { 37 | storageHash[key] = JSON.stringify(item) 38 | } else { 39 | delete storageHash[key] 40 | } 41 | 42 | sessionStorage.setItem(namespace, JSON.stringify(storageHash)) 43 | } 44 | 45 | var get = function(namespace, key, item){ 46 | var storageHash = sessionStorage.getItem(namespace) 47 | 48 | if(storageHash){ 49 | storageHash = JSON.parse(storageHash) 50 | if(storageHash[key]){ 51 | return JSON.parse(storageHash[key]) 52 | } 53 | } 54 | 55 | return null 56 | } 57 | 58 | return { 59 | set: set, 60 | get: get 61 | } 62 | })() 63 | 64 | var enabled = true 65 | 66 | var disable = function() { 67 | enabled = false 68 | } 69 | 70 | var enable = function () { 71 | enabled = true 72 | } 73 | 74 | var supported = function(){ 75 | return !!(sessionStorage && history && enabled) 76 | } 77 | 78 | var setItem = function(url, value){ 79 | if(value){ 80 | // only keep 10 things cached 81 | trimStorage() 82 | } 83 | SessionStorageHash.set("pageCache", url, value) 84 | } 85 | 86 | var getItem = function(url){ 87 | return SessionStorageHash.get("pageCache", url) 88 | } 89 | 90 | var removeItem = function(url){ 91 | setItem(url, null) 92 | } 93 | 94 | var disableAutofocusIfReplacingCachedPage = function(){ 95 | if(typeof options.removeAutofocus === "function"){ 96 | if(willUseCacheOnThisPage()){ 97 | options.removeAutofocus() 98 | } 99 | } 100 | } 101 | 102 | var cachePage = function(filterOut, callbackFunction){ 103 | if (typeof filterOut === 'function') { 104 | callbackFunction = filterOut 105 | filterOut = null 106 | } 107 | 108 | if (!supported()){ 109 | if(callbackFunction){ 110 | callbackFunction() 111 | } 112 | return; 113 | } 114 | 115 | // get jQuery animations to finish 116 | jQuery(document).finish() 117 | if (typeof options.wait === "function") 118 | options.finish() 119 | 120 | // Give transitions/animations a chance to finish 121 | setTimeout(function(){ 122 | if (typeof options.removeAutofocus === "function") 123 | options.removeAutofocus() 124 | 125 | var $cachedBody = jQuery(options.bodySelector) 126 | if (filterOut) { 127 | $cachedBody = $cachedBody.clone().find(filterOut).replaceWith("").end() 128 | } 129 | 130 | var cachedPage = { 131 | body: $cachedBody.html(), 132 | title: document.title, 133 | positionY: window.pageYOffset, 134 | positionX: window.pageXOffset, 135 | cachedAt: new Date().getTime() 136 | } 137 | 138 | // help to setup the next page of infinite scrolling 139 | if (typeof options.nextPageOffset === "function") 140 | cachedPage.nextPageOffset = options.nextPageOffset() 141 | 142 | setItem(document.location.href, cachedPage) 143 | 144 | jQuery(options.bodySelector).trigger("snapback-cache:cached", cachedPage) 145 | 146 | if(callbackFunction){ 147 | callbackFunction() 148 | } 149 | }, 500) 150 | } 151 | 152 | var loadFromCache = function(noCacheCallback){ 153 | // Check if there is a cache and if its less than 15 minutes old 154 | if(willUseCacheOnThisPage()){ 155 | var cachedPage = getItem(document.location.href) 156 | 157 | // replace the content and scroll 158 | jQuery(options.bodySelector).html(cachedPage.body) 159 | 160 | // try to make sure autofocus events don't run. 161 | if (typeof options.removeAutofocus === "function") 162 | options.removeAutofocus() 163 | 164 | // IE 10+ needs a delay to stop the autofocus during dom load 165 | setTimeout(function(){ 166 | window.scrollTo(cachedPage.positionX, cachedPage.positionY) 167 | }, 1); 168 | 169 | // pop the cache 170 | removeItem(document.location.href) 171 | 172 | jQuery(options.bodySelector).trigger("snapback-cache:loaded", cachedPage) 173 | 174 | // refresh any obsolete recordings in the activity feed 175 | var dirties = getDirties() 176 | if(dirties){ 177 | if (typeof options.refreshItems === "function") 178 | options.refreshItems(dirties) 179 | 180 | clearDirty() 181 | } 182 | 183 | return false; 184 | } 185 | else{ 186 | if(noCacheCallback){ 187 | noCacheCallback() 188 | } 189 | else{ 190 | return 191 | } 192 | } 193 | } 194 | 195 | var clearDirty = function() { 196 | sessionStorage.removeItem("pageCache-dirty") 197 | } 198 | 199 | var getDirties = function() { 200 | var raw = sessionStorage.getItem("pageCache-dirty") 201 | if (raw) { 202 | var json = JSON.parse(raw) 203 | return jQuery.map(json, function(value, key){ 204 | return key 205 | }) 206 | } else { 207 | return null 208 | } 209 | } 210 | 211 | var markDirty = function(item) { 212 | SessionStorageHash.set("pageCache-dirty", item, true) 213 | } 214 | 215 | var trimStorage = function(){ 216 | var storageHash = sessionStorage.getItem("pageCache"); 217 | if(storageHash){ 218 | storageHash = JSON.parse(storageHash); 219 | 220 | var tuples = []; 221 | 222 | for (var key in storageHash) { 223 | tuples.push([key, storageHash[key]]) 224 | } 225 | // if storage is bigger than size, sort them, and remove oldest 226 | if(tuples.length >= 10){ 227 | tuples.sort(function(a, b) { 228 | a = a[1].cachedAt; 229 | b = b[1].cachedAt; 230 | return b < a ? -1 : (b > a ? 1 : 0); 231 | }); 232 | 233 | for (var i = 0; i < (tuples.length + 1 - 10); i++) { 234 | var key = tuples[i][0]; 235 | delete storageHash[key]; 236 | } 237 | 238 | sessionStorage.setItem(namespace, JSON.stringify(storageHash)); 239 | } 240 | } 241 | } 242 | 243 | var willUseCacheOnThisPage = function(){ 244 | if (!supported()){ 245 | return false; 246 | } 247 | 248 | var cachedPage = getItem(document.location.href) 249 | 250 | // Check if there is a cache and if its less than 15 minutes old 251 | if(cachedPage && cachedPage.cachedAt > (new Date().getTime()-900000)){ 252 | return true; 253 | } 254 | else{ 255 | return false; 256 | } 257 | } 258 | 259 | jQuery(document).ready(function(){ 260 | disableAutofocusIfReplacingCachedPage() 261 | }); 262 | 263 | jQuery(window).load(function(){ 264 | loadFromCache() 265 | }); 266 | 267 | return { 268 | enable: enable, 269 | disable: disable, 270 | remove: removeItem, 271 | loadFromCache: loadFromCache, 272 | cachePage: cachePage, 273 | markDirty: markDirty, 274 | willUseCacheOnThisPage: willUseCacheOnThisPage 275 | } 276 | }); --------------------------------------------------------------------------------