├── 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 |
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 | 
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 | });
--------------------------------------------------------------------------------