Lag and Latency

tl;dr: WaniKani has lag issues during reviews, in some cases 100+ ms between questions, even without UserScripts. With several scripts, it can easily be double that. Lesson screens have even higher latency. This post explains the causes and how WaniKani’s staff could fix them, improving latency by ~6x for everyone during reviews and ~10x in lessons. A UserScript which implements many of these fixes is below, but it would be better for the fixes to be natively integrated into WaniKani. To that end, a standalone backwards compatible* permissively licensed drop in replacement for jStorage is provided below with extensive QUnit tests.

Below is a chart showing the time it takes between clicking submit until the webpage updates to show the result after each review item.** There are 6 different versions of WaniKani that were tested. In each version, the submit button was automatically clicked 40 times, resulting in some but not all of the 600 items in the review queue being completed. To ensure consistency, the review queue and order of the items reviewed was exactly the same in each test. The base scenarios tested were WaniKani as it is today without any modifications (column 1 in the chart) and WaniKani today with a bunch of common UserScripts (column 4).

Two groups of performance improvements were tested: replacing the jStorage JavaScript library used by WaniKani with an implementation that only stores data asynchronously (columns 2 and 5) and doing that plus several miscellaneous changes discussed in more detail below (columns 3 and 6). As the chart shows, you get an ~3x latency improvement from async jStorage and an additional ~2x latency improvement from also doing the other changes.

Here is some more detail about the changes that would improve things significantly:

  1. async jStorage: WaniKani itself and UserScripts heavily rely on the jStorage library. However, every time you save something in jStorage, it saves not just what you changed but everything else stored in it too. So if you have 600 outstanding reviews, every single update to any key in jStorage requires the browser to do JSON serialization of ~256kb of data which is slow. This can be fixed by modifying jStorage to only store changes in memory and only flush those changes to sessionStorage asynchronously when the visibility of the window changes. Doing this still ensures the changes get saved before the window is closed/navigated and removes the lag caused by saving. The “async jStorage” modification I tested also removes the deep copy that jStorage does when storing items in its cache, eliminating the need to do JSON serialization and deserialization of the review queue (~256kb of data) each time a review item is completed. The deep copy was not necessary as neither WaniKani nor any common scripts rely on it during reviews.***
  2. don’t use the jQuery :visible selector or any other style/layout access: Using it forces the browser to immediately recalculate styles and possibly redo layout. Some of this recalculation would have been done later anyway, but some of the work gets thrown out as additional changes are made to the DOM. Instead, keep track of the visibility of the elements that need to be shown/hidden in variables and query the variables instead.
  3. never use html(), parseHTML(), or similar functionality There are several uses of the jQuery html() function in WaniKani’s code that are just setting the plain text content of various things and could use text() instead, which is faster. For other uses, it is usually faster and cleaner to construct the DOM tree manually or to use reusable components such as React or another framework. In cases where parsing html is absolutely necessary, such as when rendering mnemonics, the mnemonic should ideally be parsed in the background before it is needed using requestIdleCallback().
  4. don’t tell jQuery to do something that results in it doing nothing If you tell jQuery to animate something to a state it is already in, there is a significant performance cost even though jQuery ultimately doesn’t change anything on the page. Examples of this on WaniKani include sometimes scrolling to a position the window is already in and fading out the loading screen after every answer, even though the window is already scrolled and the loading screen is already hidden. Also, calling hide()/show() on an already hidden/shown element has performance problems too because it triggers style recalculation. When fixing these types of problems, one needs to be careful that one does not introduce additional checks which would force the browser to calculate the style or page layout. (For example, it would be bad to fix this by explicitly checking the visibility of the loading screen in the DOM. See point 3.)
  5. don’t artifically delay the loading screen fading out Currently, the fade out of the loading screen is artificially delayed according to a timer instead of immediately disappearing when the page is ready. This suggestion doesn’t affect the times in the charts, just the initial loading time of the page.
  6. Use the keydown event instead of the keyup event for keyboard navigation in lessons. This shaves 15-75ms off each keyboard navigation in lessons.
  7. Configure your frameworks to avoid tearing down and recreating page elements When the page needs to change, it is easier to write correct code that completely trashes and regenerates a large swath of the DOM instead of only updating the DOM elements that actually changed. Thankfully, several frameworks, such as React, can automate this process, but sometimes need to be babied a bit in order to convince them that something really doesn’t need to be udpated. Luckily, there are browser extensions for React that make this process pretty straightforward by letting you see what it decided to update / not update and why. Note that this fix was deemed to difficult for me to implement/benchmark and is not included in the charts.

Below are the charts for the latency of moving between lesson screens. Due to lack of time, I only included benchmarks for WaniKani without scripts and didn’t separate out async jStorage only vs. async jStorage + all the other improvements.

wk-performance-2

For the lesson screen benchmarks above, there was only one item in the lesson queue, which was repeatedly scrolled through without refreshing the page. So even though the queue size is far smaller in these lessons tests, the latency of switching lesson screens is ~2x worse compared to submitting a review in the review tests.****

UserScript that fixes some of these issues
// ==UserScript==
// @name         WK Load Faster
// @namespace    est_fills_cando
// @version      0.5
// @description  Brings the loading screen down more quickly, removing the mandatory 1.8 second delay.
// @author       est_fills_cando
// @match        https://www.wanikani.com/review/session
// @match        https://www.wanikani.com/lesson/start
// @match        https://www.wanikani.com/lesson/session
// @grant        none
// @run-at       document-start
// ==/UserScript==

///
/// Speeding up jQuery, jStorage, the loading screen, and keyboard navigation
///
(function() {
    'use strict';

    function main() {
        window.waitProperty(window, 'jQuery', proxy_jquery);
        waitProperty(window, 'jQuery', function (jQuery) {
            waitProperty(jQuery, 'jStorage', replace_jstorage);
        });
        window.waitProperty(window, 'jQuery', function(jQuery) { jQuery(keydownNavigation) });
    }

    // WK doesn't make the loading screen removal method global in lessons for some reason
    // so we patch jQuery with a proxy that lets us modify the behavior of the method in
    // both lessons/reviews by detecting its calls to jQuery and modifying the behavior of jQuery
    // to do what we want
    // We also patch several other jQuery methods to give faster speed.
    async function proxy_jquery() {
        let pt = window.jQuery.prototype;

        // returns true if given jQuery object is the loading screen
        function is_loading_screen(jqobj) {
            return jqobj.length == 1 && ['loading-screen','loading'].includes(jqobj.get(0).id);
        }

        // don't artifically delay things relayed to the loading screen
        const old_delay = pt.delay;
        pt.delay = function() {
            if (is_loading_screen(this))
                return this;
            else
                return old_delay.call(this,...arguments);
        }

        // hide the loading screen immediately when it is no longer needed instead of a
        // gradual fade and don't waste time trying to fade it out again if we have already
        // hidden it
        const old_fadeOut = pt.fadeOut;
        let alreadyHidden = false;
        pt.fadeOut = function() {
            if (is_loading_screen(this)) {
                if (!alreadyHidden) {
                    this.hide();
                    alreadyHidden = true;
                }
                return this;
            } else {
                return old_fadeOut.call(this,...arguments);
            }
        }

        // disable this type of animated scrolling because usualy animated scrolling is triggered
        // by other things and this is invoked even when there is no need to scroll wasting time
        const old_animate = pt.animate;
        pt.animate = function(properties) {
            let names = Object.getOwnPropertyNames(properties);
            if (names.length === 1 && names[0] == 'scrollTop' && properties[names[0]] == 0)
                return this;
            else
                return old_animate.call(this, ...arguments);
        }

        // cache visibility of certain elements to avoid making the browser figure it out
        const visCache = new Map();
        const vItems = Object.freeze(['information', 'last-items', 'item-info']);
        for (let item of vItems) {
            visCache.set(item, false);
        }
        function updateVisCache(jqobjs, vis) {
            for (let el of jqobjs) {
                if (vItems.includes(el.id))
                    visCache.set(el.id, vis);
            }
        }

        const old_is = pt.is;
        pt.is = function(selector) {
            if (selector === ':visible' && this.length === 1 && vItems.includes(this.get(0).id)) {
                let c = visCache.get(this.get(0).id) && visCache.get('information');
                //if (old_is.call(this, ...arguments) !== c) throw 'bad cache';
                return c;
            } else {
                return old_is.call(this, ...arguments);
            }
        }

        const old_hide = pt.hide;
        pt.hide = function() {
            updateVisCache(this, false);
            return old_hide.call(this, ...arguments);
        }

        const old_show = pt.show;
        pt.show = function() {
            updateVisCache(this, true);
            return old_show.call(this, ...arguments);
        }


        // setting innerHTML is slightly faster if we check for non-html content
        // and set that using textContent
        const textOnly = /^[^<>"'&]*$/u;
        let old_html = pt.html;
        pt.html = function (html) {
            const t = typeof html;
            if (t === 'number' || t === 'string' && html.match(textOnly)) {
                this.each( function () { this.textContent = html; } );
            } else {
                return old_html.call(this,...arguments);
            }
        };
    }

    function keydownNavigation() {
        if (window.location.pathname.startsWith('/lesson')) {
            function keyDownToUp(options) {
                let alreadySent = false;
                let start = null;
                document.addEventListener('keydown', function(evt) {
                    if (evt.key == options.key && !alreadySent && evt.target.tagName.toLowerCase() != 'input') {
                        alreadySent = true;

                        let fakeEvt = new KeyboardEvent('keyup',options);
                        fakeEvt.wklkd_fakeevt = true;
                        document.querySelector('#next-btn').dispatchEvent(fakeEvt);
                    }
                }, true);

                document.body.addEventListener('keyup', function(evt) {
                    if (evt.key == options.key && !evt.wklkd_fakeevt && alreadySent) {
                        alreadySent = false;
                        evt.stopPropagation();
                        evt.preventDefault();
                        return false;
                    }
                }, true);
            }

            keyDownToUp({'key':'Enter', code: 'Enter', which:13, location: 0, keyCode:13, bubbles:true});
            keyDownToUp({'key':'ArrowLeft', code: 'ArrowLeft', which:37, location: 0, keyCode:37, bubbles:true});
            keyDownToUp({'key':'ArrowRight', code: 'ArrowRight', which:39, location: 0, keyCode:39, bubbles:true});
            keyDownToUp({'key':'a', code: 'Keya', which:65, location: 0, keyCode:65, bubbles:true});
            keyDownToUp({'key':'d', code: 'Keyd', which:68, location: 0, keyCode:68, bubbles:true});
            keyDownToUp({'key':'j', code: 'Keyj', which:74, location: 0, keyCode:74, bubbles:true});
            keyDownToUp({'key':'q', code: 'Keyq', which:81, location: 0, keyCode:81, bubbles:true});
            keyDownToUp({'key':'g', code: 'Keyg', which:71, location: 0, keyCode:71, bubbles:true});
        }
    }

    // helper method for waiting for a property to be defined on an element
    // callback is called synchronously immediately after the property is defined
    if (!window.waitProperty) {
        let objPropCallbacks = new Map();
        window.waitProperty = function (obj, prop, callback) {
            if (obj[prop] !== undefined) {
                callback(obj[prop]);
                return;
            }
            if (!objPropCallbacks.has(obj))
                objPropCallbacks.set(obj, new Map());
            let propCallbacks = objPropCallbacks.get(obj);

            let callbacks;
            if (!propCallbacks.has(prop)) {
                propCallbacks.set(prop, []);

                function runCallbacks(val) {
                    for (let callback of callbacks) {
                        callback(val);
                    }
                }

                let _val;
                Object.defineProperty(obj, prop, {
                    get: () => _val,
                    set: function(val) {_val = val; delete obj[prop]; obj[prop] = val; runCallbacks(val); callbacks.length = 0;},
                    configurable: true,
                    enumerable: true
                });
            }
            callbacks = propCallbacks.get(prop);
            callbacks.push(callback);
        }
    }

    ///
    /// Speed up jStorage by replacing it with a much faster version.
    ///
    function jStorage(localOnly, debugOptions) {
        'use strict';

        const realWindow = window;
        return function () { // IEF because we need to declare local window after accessing global
            let window;
            if (!debugOptions) {
                debugOptions = {};
            }
            if (debugOptions.window) {
                window = debugOptions.window;
            } else {
                window = realWindow;
            }

            const jStorage = {}; // the main jStorage object that exposes the API, shadows function name above
            const callbacks = {}; // callbacks for event listeners
            const backend = window.sessionStorage; // where the data ultimately gets stored
            const name = 'sessionStorage';
            const persistent = true; // whether backend is persistent
            const cache = {}; // in memory read cache / complete representation of the stored data
            let timeout = null; // timeout handle of timeout used to delete keys that have expired due to ttl
            let soonestExpiration = Infinity; // soonest expiration time of any key

            jStorage.version = 'lazyshallow-0.5.0';

            jStorage.storageAvailable = function () {return persistent};

            jStorage.currentBackend = function () {return name};

            jStorage.storageSize = function () {
                return JSON.stringify(jStorage._getOldFormat()).length;
            };

            jStorage.reInit = function () {
                if (backend.jStorage) {
                    const obj = JSON.parse(backend.jStorage);
                    const expiration = obj.__jstorage_meta.TTL;
                    delete obj.__jstorage_meta;
                    for (let key in obj) {
                        if (obj.hasOwnProperty(key)) {
                            const exp = expiration[key];
                            cache[key] = [obj[key], exp ? exp : 0];
                            this._scheduleCleanup(key, exp, true);
                            // should not and does not fire observers
                        }
                    }
                }
            };

            jStorage.get = function (key, def) {
                this._checkKey(key);
                if (cache.hasOwnProperty(key)) {
                    if (typeof cache[key][0] === 'object' && !Object.isExtensible(cache[key][0])) {
                        cache[key][0] = deepcopy(cache[key][0]);
                    }
                    const val = cache[key][0];
                    return val === undefined ? null : val;
                }
                return typeof(def) == 'undefined' ? null : def;
            };

            jStorage.getTTL = function (key) {
                this._checkKey(key);
                if (cache.hasOwnProperty(key)) {
                    const ttl = cache[key][1];
                    if (ttl) {
                        return ttl - (+new Date());
                    } else {
                        return 0;
                    }
                }
                return 0;
            };

            jStorage.deleteKey = function (key) {
                this._checkKey(key);
                if (cache.hasOwnProperty(key)) {
                    delete cache[key];
                    this._fireObservers(key, 'deleted');
                    return true;
                }
                return false;
            };

            jStorage.flush = function () {
                for (let key in cache) {
                    if (cache.hasOwnProperty(key)) {
                        this.deleteKey(key);
                    }
                }
                return true;
            };

            jStorage.index = function() {
                return Object.getOwnPropertyNames(cache);
            };

            jStorage.listenKeyChange = function (key, callback) {
                if (key !== '*') {
                    this._checkKey(key);
                }
                if (!callbacks[key]) {
                    callbacks[key] = [];
                }
                callbacks[key].push(callback);
            };

            jStorage.stopListening = function (key, callback) {
                if (key !== '*') {
                    this._checkKey(key);
                }
                if (!callback) {
                    delete callbacks[key];
                } else {
                    if (callbacks[key]) {
                        callbacks[key] = callbacks[key].filter(function (cb) { return cb !== callback });
                    }
                }
            };

            jStorage.set = function (key, val, options) {
                if (!options) {
                    options = {};
                }
                this._checkKey(key);
                if (val === null || val === undefined) {
                    this.deleteKey(key);
                    return val;
                } else {
                    cache[key] = [val, 0];
                    this.setTTL(key, options.TTL || 0);
                    this._fireObservers(key, 'updated');
                    return val;
                }
            };

            jStorage.setTTL = function (key, ttl) {
                this._checkKey(key);
                if (cache.hasOwnProperty(key)) {
                    if (ttl && ttl > 0 ) {
                        cache[key][1] = ttl + (+new Date());
                    } else {
                        cache[key][1] = 0;
                    }
                    this._scheduleCleanup(key, cache[key][1], false);
                    return true;
                }
                return false;
            };

            jStorage.noConflict = function(saveInGlobal) {
                delete window.$.jStorage;
                if (saveInGlobal) {
                    window.jStorage = this;
                }
                return this;
            };

            // dispatches event to listeners registered with listenKeyChange
            jStorage._fireObservers = function (keys, type) {
                keys = [].concat(keys || []);
                for (let i=0; i<keys.length; i++) {
                    const key = keys[i];
                    const matchingCallbacks = (callbacks[key] || []).concat(callbacks['*'] || []);
                    for (let j=0; j<matchingCallbacks.length; j++) {
                        matchingCallbacks[j](key, type, cache[key]);
                    }
                }
            };

            // verify key is a valid key
            jStorage._checkKey = function (key) {
                if (typeof key != 'string' && typeof key != 'number') {
                    throw new TypeError('Key name must be string or numeric');
                } else if (key === '*') {
                    throw new TypeError('keyname/wildcard selector not allowed');
                } else if (key === '__jStorage_meta') {
                    throw new TypeError('Reserved key name');
                }
                return true;
            };

            // schedules or reschedules the next TTL cleanup taking into account
            // the given expiration timestamp
            // If synchronous is true, the item will be cleaned up immediately if it
            // has already expired as of the start of the call.
            jStorage._scheduleCleanup = function(key, expiration, synchronous) {
                if (expiration === 0) {
                    return;
                } else if (synchronous && expiration < +new Date()) {
                    this.deleteKey(key);
                } else if (expiration < soonestExpiration) {
                    soonestExpiration = expiration;
                    clearTimeout(timeout);
                    if (soonestExpiration < Infinity) {
                        setTimeout(_cleanupTTL, soonestExpiration - (+new Date()));
                    }
                }
            }

            // delete everything with expired ttl and schedule this to be called
            // again at the soonest expiration time of the remaining elements
            jStorage._cleanupTTL = function () {
                if (+new Date() > soonestExpiration) {
                    for (let key in cache) {
                        if (this.getTTL(key) < 0 && cache.hasOwnProperty(key)) {
                            this.deleteKey(key);
                        }
                    }

                    let newSoonest = Infinity
                    for (let key in cache) {
                        if (cache.hasOwnProperty(key) && cache[key][1] !== 0 && cache[key][1] < newSoonest) {
                            newSoonest = cache[key][1];
                        }
                    }
                    soonestExpiration = newSoonest;
                }
                if (soonestExpiration < Infinity) {
                    timeout = setTimeout(_cleanupTTL, soonestExpiration - (+new Date()) );
                }
            };

            jStorage._getAll  = function() {
                const obj = {};
                for (let key in cache) {
                    if (cache.hasOwnProperty(key)) {
                        obj[key] = cache[key][0];
                    }
                }
                return obj;
            };

            jStorage._getExpirations  = function() {
                const expirations = {};
                for (let key in cache) {
                    if (cache[key][1] !== 0) {
                        expirations[key] = cache[key][1];
                    }
                }
                return expirations;
            };

            jStorage._getOldFormat = function () {
                const oldFormat = Object.assign( jStorage._getAll(), {"__jstorage_meta":{"CRC32":{}}});
                oldFormat.__jstorage_meta.TTL = jStorage._getExpirations();
                return oldFormat;
            }


            const deepcopy = function (obj) {
                const t = typeof obj;
                if (obj === undefined || obj === null || t === 'string' || t === 'number' || t === 'boolean') {
                    return obj;
                } else if (typeof obj === 'object' && obj.toJSON === undefined) {
                    if (Array.isArray(obj)) {
                        const len = obj.length;
                        const newArr = new Array(len);
                        for (let i = 0; i < len; i++) {
                            newArr[i] = deepcopy(obj[i]);
                        }
                        return newArr;
                    } else {
                        const newObj = {};
                        for (let key in obj) {
                            if (obj.hasOwnProperty(key)) {
                                newObj[key] = deepcopy(obj[key]);
                            }
                        }
                        return newObj;
                    }
                }
                throw 'bad deep copy';
            };

            if (!String.prototype.startsWith) {
                Object.defineProperty(String.prototype, 'startsWith', {value: function(search, rawPos) {
                    const pos = rawPos > 0 ? rawPos|0 : 0;
                    return this.substring(pos, pos + search.length) === search;
                }});
            }

            if (!Object.assign) {
                Object.assign = function (target, source) {
                    for (let prop in source) {
                        if (source.hasOwnProperty(prop)) {
                            target[prop] = source[prop]
                        }
                    }
                    return target;
                }
            }

            if (typeof JSTORAGE_DEBUG_EXTENSIONS !== 'undefined' ) {
                JSTORAGE_DEBUG_EXTENSIONS(jStorage, debugOptions);
            }

            const _cleanupTTL = jStorage._cleanupTTL.bind(jStorage);

            // save changes back in format old jStorage can read
            window.document.addEventListener('visibilitychange', function() {
                backend.jStorage = JSON.stringify(jStorage._getOldFormat());
            });

            jStorage.reInit();

            if (!localOnly) {
                realWindow.$.jStorage = jStorage;
            }
            // cleanup scope
            if (!realWindow.JSTORAGE_DEBUG && realWindow.jStorage === jStorage) {
                delete realWindow.jStorage;
            }
            return jStorage;
        }();
    }

    ///
    /// Code for replacing jStorage after the old version has already loaded
    ///
    function replace_jstorage() {
        let observers = getObservers(true); // get copy of observers already registered to old jStorage and clear them from old jStorage

        let js = jStorage(true);

        // clear cache from old jStorage
        $.jStorage.flush();

        // replace existing reference to old jStorage with new jStorage
        let oldJStorage = Object.assign({}, $.jStorage);
        Object.assign($.jStorage, js);

        // add event listeners to new jStorage
        for (let key of Object.getOwnPropertyNames(observers)) {
            for (let callback of observers[key]) {
                js.listenKeyChange(key, callback);
            }
        }

        $.jStorage = js;
    }

    // exploit fact that _observers.hasOwnProperty gets called during event firing for 'flushed' events
    // by temporarily monkeypatching hasOwnProperty in the prototype of all objects
    function getObservers(clear) {
        let obj = {};
        let oldF = obj.__proto__.hasOwnProperty;
        let _observers;
        $.jStorage.listenKeyChange('wk-lazy-jstorage-dummy', function () {});
        $.jStorage.set('wk-lazy-jstorage-dummy', 1);
        let oldData = sessionStorage.jStorage;
        let observersCopy;
        obj.__proto__.hasOwnProperty = function() {
            _observers = this;
            observersCopy = Object.assign({}, _observers);
            // clear observers registered with old jStorage
            for (let key in _observers)
                if (oldF.call(_observers,key))
                    delete _observers[key];
            return oldF.call(this);
        };
        $.jStorage.flush();
        obj.__proto__.hasOwnProperty = oldF;
        sessionStorage.jStorage = oldData;
        delete observersCopy['wk-lazy-jstorage-dummy'];
        $.jStorage.reInit();
        $.jStorage.deleteKey('wk-lazy-jstorage-dummy');
        if (!clear)
            Object.assign(_observers, observersCopy);
        return observersCopy;
    }

    main();
})();
Just the jStorage changes
/// Distributed under the Apache 2.0 License

///
/// Speed up jStorage by replacing it with a much faster version.
///
function jStorage(localOnly, debugOptions) {
    'use strict';

    const realWindow = window;
    return function () { // IEF because we need to declare local window after accessing global
        let window;
        if (!debugOptions) {
            debugOptions = {};
        }
        if (debugOptions.window) {
            window = debugOptions.window;
        } else {
            window = realWindow;
        }

        const jStorage = {}; // the main jStorage object that exposes the API, shadows function name above
        const callbacks = {}; // callbacks for event listeners
        const backend = window.sessionStorage; // where the data ultimately gets stored
        const name = 'sessionStorage';
        const persistent = true; // whether backend is persistent
        const cache = {}; // in memory read cache / complete representation of the stored data
        let timeout = null; // timeout handle of timeout used to delete keys that have expired due to ttl
        let soonestExpiration = Infinity; // soonest expiration time of any key

        jStorage.version = 'lazyshallow-0.5.0';

        jStorage.storageAvailable = function () {return persistent};

        jStorage.currentBackend = function () {return name};

        jStorage.storageSize = function () {
            return JSON.stringify(jStorage._getOldFormat()).length;
        };

        jStorage.reInit = function () {
            if (backend.jStorage) {
                const obj = JSON.parse(backend.jStorage);
                const expiration = obj.__jstorage_meta.TTL;
                delete obj.__jstorage_meta;
                for (let key in obj) {
                    if (obj.hasOwnProperty(key)) {
                        const exp = expiration[key];
                        cache[key] = [obj[key], exp ? exp : 0];
                        this._scheduleCleanup(key, exp, true);
                        // should not and does not fire observers
                    }
                }
            }
        };

        jStorage.get = function (key, def) {
            this._checkKey(key);
            if (cache.hasOwnProperty(key)) {
                if (typeof cache[key][0] === 'object' && !Object.isExtensible(cache[key][0])) {
                    cache[key][0] = deepcopy(cache[key][0]);
                }
                const val = cache[key][0];
                return val === undefined ? null : val;
            }
            return typeof(def) == 'undefined' ? null : def;
        };

        jStorage.getTTL = function (key) {
            this._checkKey(key);
            if (cache.hasOwnProperty(key)) {
                const ttl = cache[key][1];
                if (ttl) {
                    return ttl - (+new Date());
                } else {
                    return 0;
                }
            }
            return 0;
        };

        jStorage.deleteKey = function (key) {
            this._checkKey(key);
            if (cache.hasOwnProperty(key)) {
                delete cache[key];
                this._fireObservers(key, 'deleted');
                return true;
            }
            return false;
        };

        jStorage.flush = function () {
            for (let key in cache) {
                if (cache.hasOwnProperty(key)) {
                    this.deleteKey(key);
                }
            }
            return true;
        };

        jStorage.index = function() {
            return Object.getOwnPropertyNames(cache);
        };

        jStorage.listenKeyChange = function (key, callback) {
            if (key !== '*') {
                this._checkKey(key);
            }
            if (!callbacks[key]) {
                callbacks[key] = [];
            }
            callbacks[key].push(callback);
        };

        jStorage.stopListening = function (key, callback) {
            if (key !== '*') {
                this._checkKey(key);
            }
            if (!callback) {
                delete callbacks[key];
            } else {
                if (callbacks[key]) {
                    callbacks[key] = callbacks[key].filter(function (cb) { return cb !== callback });
                }
            }
        };

        jStorage.set = function (key, val, options) {
            if (!options) {
                options = {};
            }
            this._checkKey(key);
            if (val === null || val === undefined) {
                this.deleteKey(key);
                return val;
            } else {
                cache[key] = [val, 0];
                this.setTTL(key, options.TTL || 0);
                this._fireObservers(key, 'updated');
                return val;
            }
        };

        jStorage.setTTL = function (key, ttl) {
            this._checkKey(key);
            if (cache.hasOwnProperty(key)) {
                if (ttl && ttl > 0 ) {
                    cache[key][1] = ttl + (+new Date());
                } else {
                    cache[key][1] = 0;
                }
                this._scheduleCleanup(key, cache[key][1], false);
                return true;
            }
            return false;
        };

        jStorage.noConflict = function(saveInGlobal) {
            delete window.$.jStorage;
            if (saveInGlobal) {
                window.jStorage = this;
            }
            return this;
        };

        // dispatches event to listeners registered with listenKeyChange
        jStorage._fireObservers = function (keys, type) {
            keys = [].concat(keys || []);
            for (let i=0; i<keys.length; i++) {
                const key = keys[i];
                const matchingCallbacks = (callbacks[key] || []).concat(callbacks['*'] || []);
                for (let j=0; j<matchingCallbacks.length; j++) {
                    matchingCallbacks[j](key, type, cache[key]);
                }
            }
        };

        // verify key is a valid key
        jStorage._checkKey = function (key) {
            if (typeof key != 'string' && typeof key != 'number') {
                throw new TypeError('Key name must be string or numeric');
            } else if (key === '*') {
                throw new TypeError('keyname/wildcard selector not allowed');
            } else if (key === '__jStorage_meta') {
				throw new TypeError('Reserved key name');
			}
            return true;
        };

        // schedules or reschedules the next TTL cleanup taking into account
        // the given expiration timestamp
        // If synchronous is true, the item will be cleaned up immediately if it
        // has already expired as of the start of the call.
        jStorage._scheduleCleanup = function(key, expiration, synchronous) {
            if (expiration === 0) {
                return;
            } else if (synchronous && expiration < +new Date()) {
                this.deleteKey(key);
            } else if (expiration < soonestExpiration) {
                soonestExpiration = expiration;
                clearTimeout(timeout);
                if (soonestExpiration < Infinity) {
                    setTimeout(_cleanupTTL, soonestExpiration - (+new Date()));
                }
            }
        }

        // delete everything with expired ttl and schedule this to be called
        // again at the soonest expiration time of the remaining elements
        jStorage._cleanupTTL = function () {
            if (+new Date() > soonestExpiration) {
                for (let key in cache) {
                    if (this.getTTL(key) < 0 && cache.hasOwnProperty(key)) {
                        this.deleteKey(key);
                    }
                }

                let newSoonest = Infinity
                for (let key in cache) {
                    if (cache.hasOwnProperty(key) && cache[key][1] !== 0 && cache[key][1] < newSoonest) {
                        newSoonest = cache[key][1];
                    }
                }
                soonestExpiration = newSoonest;
            }
            if (soonestExpiration < Infinity) {
                timeout = setTimeout(_cleanupTTL, soonestExpiration - (+new Date()) );
            }
        };

        jStorage._getAll  = function() {
            const obj = {};
            for (let key in cache) {
                if (cache.hasOwnProperty(key)) {
                    obj[key] = cache[key][0];
                }
            }
            return obj;
        };

        jStorage._getExpirations  = function() {
            const expirations = {};
            for (let key in cache) {
                if (cache[key][1] !== 0) {
                    expirations[key] = cache[key][1];
                }
            }
            return expirations;
        };
		
		jStorage._getOldFormat = function () {
			const oldFormat = Object.assign( jStorage._getAll(), {"__jstorage_meta":{"CRC32":{}}});
            oldFormat.__jstorage_meta.TTL = jStorage._getExpirations();
			return oldFormat;
		}

        if (!String.prototype.startsWith) {
            Object.defineProperty(String.prototype, 'startsWith', {value: function(search, rawPos) {
                const pos = rawPos > 0 ? rawPos|0 : 0;
                return this.substring(pos, pos + search.length) === search;
            }});
        }
		
		if (!Object.prototype.assign) {
			Object.prototype.assign = function (target, source) {
				for (let prop in source) {
					if (source.hasOwnProperty(prop)) {
						target[prop] = source[prop]
					}
				}
				return target;
			}
		}

        if (typeof JSTORAGE_DEBUG_EXTENSIONS !== 'undefined' ) {
            JSTORAGE_DEBUG_EXTENSIONS(jStorage, debugOptions);
        }

        const _cleanupTTL = jStorage._cleanupTTL.bind(jStorage);

        // save changes back in format old jStorage can read
        window.document.addEventListener('visibilitychange', function() {
            backend.jStorage = JSON.stringify(jStorage._getOldFormat());
        });

        jStorage.reInit();

        if (!localOnly) {
            realWindow.$.jStorage = jStorage;
        }
        // cleanup scope
        if (!realWindow.JSTORAGE_DEBUG && realWindow.jStorage === jStorage) {
            delete realWindow.jStorage;
        }
        return jStorage;
    }();
}

const deepcopy = function (obj) {
    const t = typeof obj;
    if (obj === undefined || obj === null || t === 'string' || t === 'number' || t === 'boolean') {
        return obj;
    } else if (typeof obj === 'object' && obj.toJSON === undefined) {
        if (Array.isArray(obj)) {
            const len = obj.length;
            const newArr = new Array(len);
            for (let i = 0; i < len; i++) {
                newArr[i] = deepcopy(obj[i]);
            }
            return newArr;
        } else {
            const newObj = {};
            for (let key in obj) {
                if (obj.hasOwnProperty(key)) {
                    newObj[key] = deepcopy(obj[key]);
                }
            }
            return newObj;
        }
    }
    throw 'bad deep copy';
};

jStorage();

Extensive QUnit Tests for jStorage changes
// Distributed under Apache 2.0 License

objAssign = function (target, source) {
	for (let prop in source) {
		if (source.hasOwnProperty(prop)) {
			target[prop] = source[prop]
		}
	}
	return target;
}

let stCalled = 0;
let cbCalled = 0;
let oldSetTimeout = window.setTimeout;
window.setTimeout = function(callback, t) {
	stCalled++;
	return oldSetTimeout(function() {cbCalled++; return callback();}, t);
};

function TimeoutsMonitor() {	
	this.reset = function() {
		this.stCalledInitial = stCalled;
		this.cbCalledInitial = cbCalled;
	};
	
	this.timeouts = function() {
		return stCalled - this.stCalledInitial;
	};
	
	this.callbacks = function() {
		return cbCalled - this.cbCalledInitial;
	};
	
	this.tlt = function(ub) {
		return ok(this.timeouts() <= ub, '# calls to setTimeout: ' + this.timeouts());
	};
	
	this.cblt = function(ub) {
		return ok(this.callbacks() <= ub, '# callbacks by setTimeout: ' + this.callbacks());
	};	
	
	this.reset();
}

function getBasicWindow(window) {
	return {JSON:window.JSON, addEventListener:window.addEventListener.bind(window), document:{addEventListener:window.document.addEventListener.bind(window.document)}};
}

function DummyStorage() {
	Object.defineProperty(this, 'setItem', {enumerable: false, value: function (key, val) {this[key] = val} });
	Object.defineProperty(this, 'getItem', {enumerable: false, value: function (key, val) {return this[key]} });
	Object.defineProperty(this, 'removeItem', {enumerable: false, value: function (key, val) {delete this[key]} });
}

module( "test environment" );

test( "test runner is allowed to open popup windows", function () {
	const w = window.open('about:blank');
	if (!w) {
		ok(false, 'you must configure your browser to allow popup windows in order for certain tests to successfully run');
	} else {
		ok(true);
		w.close();
	}
});

test( "sessionStorage exists in test environment", function () {
	ok(sessionStorage);
});

module( "browser behavior does not violate assumptions" );

test( "sessionStorage is not bidirectionally synchronized across tabs", function() {
	const a = window.open('about:blank');
	const b = window.open('about:blank');
	a.window.sessionStorage.test = 'a';
	b.window.sessionStorage.test = 'b';
	window.sessionStorage.test = '';
	strictEqual(a.window.sessionStorage.test, 'a');
	strictEqual(b.window.sessionStorage.test, 'b');
	strictEqual(window.sessionStorage.test, '');
	a.close();
	b.close();
	strictEqual(window.sessionStorage.test, '');
})

module( "basic / misc functionality" );

test( "flush/index", function() {
	strictEqual($.jStorage.flush(), true);
	deepEqual($.jStorage.index(), []);
	$.jStorage.set("test", "value");
	deepEqual($.jStorage.index(), ["test"]);
	strictEqual($.jStorage.flush(), true);
	deepEqual($.jStorage.index(), [])
	$.jStorage.set('test2', null);
	$.jStorage.set('test3', 'value');
	$.jStorage.set('test3', null);
	deepEqual($.jStorage.index(), []);
	strictEqual($.jStorage.get("test"), null);
	strictEqual($.jStorage.get("test2"), null);
	strictEqual($.jStorage.get("test3"), null);
	deepEqual($.jStorage.index(), []);
	//let js = jStorage(true);
	//deepEqual(js.index(), []);
	$.jStorage.flush();
});

test( "_getAll", function () {
	if ($.jStorage._getAll) {
		$.jStorage.flush();
		deepEqual($.jStorage._getAll(), {});
		$.jStorage.set('testkey', 'value');
		deepEqual($.jStorage._getAll(), {'testkey': 'value'});
		$.jStorage.flush();
		return;
	}
	expect(0);
});

test( "storageSize", function() {
	$.jStorage.flush();
	let size = $.jStorage.storageSize();
	ok(size <= 50);
	$.jStorage.set('k1', 'v1');
	ok($.jStorage.storageSize() > size);
	size = $.jStorage.storageSize();
	$.jStorage.set('k1', 'v1');
	equal($.jStorage.storageSize(), size);
	$.jStorage.set('k2', 'v2');
	ok($.jStorage.storageSize() > size);
	$.jStorage.flush();
});

module( "set" );

test("undefined", function() {
  	strictEqual($.jStorage.set("test", undefined), undefined);
	strictEqual($.jStorage.get("test"), null);
  	$.jStorage.flush();
});

test("zero", function() {
	strictEqual($.jStorage.set("test", 0), 0);
  	strictEqual($.jStorage.get("test"), 0);
  	$.jStorage.flush();
});

test("missing", function() {
  	strictEqual($.jStorage.get("test"), null);
  	$.jStorage.flush();
});

test("use default", function() {
	$.jStorage.set("value exists", "value");
  	strictEqual($.jStorage.get("no value", "def"), "def");
  	strictEqual($.jStorage.get("value exists", "def"), "value");
  	$.jStorage.flush();
});

test("string", function() {
	strictEqual($.jStorage.set("test", "value"), "value");
  	strictEqual($.jStorage.get("test"), "value");
  	$.jStorage.flush();
});

test("boolean", function() {
	strictEqual($.jStorage.set("test true", true), true);
  	strictEqual($.jStorage.get("test true"), true);
  	strictEqual($.jStorage.set("test false", false), false);
  	strictEqual($.jStorage.get("test false"), false);
  	$.jStorage.flush();
});

test("number", function() {
	strictEqual($.jStorage.set("test", 10.01), 10.01);
  	strictEqual($.jStorage.get("test"), 10.01);
	strictEqual($.jStorage.set(3, "value"), "value");
  	strictEqual($.jStorage.get(3), "value");
  	$.jStorage.flush();
});

test("object", function() {
	let testObj  = {arr:[1,2,3], 4:5, true:6, false:7, 8:true, 9:false, 10:null, 11:0, 0:12};
	let original = {arr:[1,2,3], 4:5, true:6, false:7, 8:true, 9:false, 10:null, 11:0, 0:12};
	deepEqual($.jStorage.set("test", testObj), original);
	deepEqual(testObj, original);
	propEqual($.jStorage.get("test"), original);
  	$.jStorage.flush();
});

module('delete');

test("deleteKey", function() {
	deepEqual($.jStorage.index(), []);
	$.jStorage.set("test", "value");
	deepEqual($.jStorage.index(), ["test"]);
	strictEqual($.jStorage.deleteKey("test"), true);
	strictEqual($.jStorage.deleteKey("test"), false);
	deepEqual($.jStorage.index(), []);
  	$.jStorage.flush();
});

module('ttl lifecycle');

asyncTest("TTL", function() {
	expect(2);
	$.jStorage.set("ttlkey", "value", {TTL:500});
	setTimeout(function(){
		equal($.jStorage.get("ttlkey"), "value");
		setTimeout(function(){
			strictEqual($.jStorage.get("ttlkey"), null);
			$.jStorage.flush();
			start();
		}, 500);
	}, 250);
});

asyncTest("setTTL", function() {
	expect(2);
	$.jStorage.set("ttlkey", "value");
	$.jStorage.setTTL("ttlkey", 500);
	setTimeout(function(){
		equal($.jStorage.get("ttlkey"), "value");
		setTimeout(function(){
			strictEqual($.jStorage.get("ttlkey"), null);
			$.jStorage.flush();
			start();
		}, 500);
	}, 250);
});

asyncTest("setTTL - no expiration", function() {
	$.jStorage.set("ttlkey", "value");
	$.jStorage.setTTL("ttlkey", 0);
	setTimeout(function(){
		equal($.jStorage.get("ttlkey"), "value");
		$.jStorage.flush();
		start();
	}, 100);
});

test("setTTL - negative ttl", function() {
	$.jStorage.set("ttlkey", "value");
	strictEqual($.jStorage.get("ttlkey"), "value");
	$.jStorage.setTTL("ttlkey", -1);
	strictEqual($.jStorage.getTTL("ttlkey"), 0);
	strictEqual($.jStorage.get("ttlkey"), "value");
	$.jStorage.flush();
});

asyncTest("getTTL", function() {
	expect(3);
	$.jStorage.set("ttlkey", "value", {TTL: 500});
	setTimeout(function(){
		ok($.jStorage.getTTL("ttlkey") > 0);
		ok($.jStorage.getTTL("ttlkey") < 400);
		setTimeout(function(){
			strictEqual($.jStorage.getTTL("ttlkey"), 0);
			$.jStorage.flush();
			start();
		}, 500);
	}, 250);
});

asyncTest("getTTL - no expiration", function() {
	$.jStorage.set("ttlkey", "value", {TTL: 0});
	setTimeout(function(){
		equal($.jStorage.getTTL("ttlkey"), 0);
		$.jStorage.flush();
		start();
	}, 100);
});

asyncTest("increasing chain triggers okay", function() {
	expect(10);
	let tm = new TimeoutsMonitor();
	$.jStorage.set("ttlkey1", "value", {TTL:500});
	$.jStorage.set("ttlkey2", "value", {TTL:1000});
	strictEqual($.jStorage.get("ttlkey1"), "value");
	strictEqual($.jStorage.get("ttlkey2"), "value")
	setTimeout(function(){
		tm.cblt(5);
		tm.tlt(5);
		strictEqual($.jStorage.get("ttlkey1"), null);
		strictEqual($.jStorage.get("ttlkey2"), "value");
		setTimeout(function(){
			tm.cblt(10);
			tm.tlt(15);
			strictEqual($.jStorage.get("ttlkey1"), null);
			strictEqual($.jStorage.get("ttlkey2"), null);
			$.jStorage.flush();
			start();
		}, 500);
	}, 750);
});

asyncTest("decreasing chain triggers okay", function() {
	expect(10);
	let tm = new TimeoutsMonitor();
	$.jStorage.set("ttlkey1", "value", {TTL:1000});
	$.jStorage.set("ttlkey2", "value", {TTL:500});
	strictEqual($.jStorage.get("ttlkey1"), "value");
	strictEqual($.jStorage.get("ttlkey2"), "value")
	setTimeout(function(){		
		tm.cblt(5);
		tm.tlt(5);
		strictEqual($.jStorage.get("ttlkey1"), "value");
		strictEqual($.jStorage.get("ttlkey2"), null);
		setTimeout(function(){
			tm.cblt(10);
			tm.tlt(15)
			strictEqual($.jStorage.get("ttlkey1"), null);
			strictEqual($.jStorage.get("ttlkey2"), null);
			$.jStorage.flush();
			start();
		}, 500);
	}, 750);
});

module("listeners");

asyncTest("stop listening - specific key", function() {
	expect(1);
	$.jStorage.listenKeyChange("testkey", function(key, action){
		console.error(key,action);
		$.jStorage.flush();
		ok(false);
		start();
	});
	$.jStorage.stopListening("testkey");
	
	$.jStorage.set("testkey", "value");
	setTimeout(function(){
		ok(true);
		$.jStorage.flush();
		start();
	}, 100);
});

test("specific key - updated", function() {
	let called = 0;
	$.jStorage.listenKeyChange("testkey", function(key, action){
		equal($.jStorage.get('testkey'), 'value');
		equal(key, "testkey");
		equal(action, "updated");
		called++;
	});
	equal(called, 0); // listener not yet called
	$.jStorage.set('testkey', 'value');
	equal(called, 1);
	$.jStorage.stopListening("testkey");
	$.jStorage.flush();
});

test("any key - updated", function() {
	let called = 0;
	$.jStorage.listenKeyChange("*", function(key, action){
		equal($.jStorage.get('testkey'), 'value');
		equal(key, "testkey");
		equal(action, "updated");
		called++;
	});
	equal(called, 0); // listener not yet called
	$.jStorage.set('testkey', 'value');
	equal(called, 1);
	$.jStorage.stopListening("*");
	$.jStorage.flush();
});

test("specific key - deleted", function() {
	$.jStorage.set("testkey", "value");
	let called = 0;
	$.jStorage.listenKeyChange("testkey", function(key, action){
		equal($.jStorage.get('testkey'), null);
		equal(key, "testkey");
		equal(action, "deleted");
		called++;
	});
	equal(called, 0); // listener not yet called
	$.jStorage.deleteKey("testkey");
	equal(called, 1);
	$.jStorage.stopListening("testkey");
	$.jStorage.flush();
});

test("any key - deleted", function() {
	$.jStorage.set("testkey", "value");
	let called = 0;
	$.jStorage.listenKeyChange("*", function(key, action){
		equal($.jStorage.get('testkey'), null);
		equal(key, "testkey");
		equal(action, "deleted");
		called++;
	});
	equal(called, 0); // listener not yet called
	$.jStorage.deleteKey("testkey");
	equal(called, 1);
	$.jStorage.stopListening("*");
	$.jStorage.flush();
});

test( "deleting nonexistant key" , function(){
	let called = 0;
	$.jStorage.listenKeyChange("testkey", function(key, action){
		called++;
	});
	$.jStorage.listenKeyChange("*", function(key, action){
		called++;
	});
	$.jStorage.deleteKey("testkey");
	equal(called, 0);
	$.jStorage.stopListening("testkey");
	$.jStorage.stopListening("*");
	$.jStorage.flush();
});

test( "setTTL" , function(){
	$.jStorage.set("testkey", "value");
	let called = 0;
	$.jStorage.listenKeyChange("testkey", function(key, action){
		called++;
	});
	$.jStorage.listenKeyChange("*", function(key, action){
		called++;
	});
	$.jStorage.setTTL("testkey", 500);
	equal(called, 0);
	$.jStorage.stopListening("testkey");
	$.jStorage.stopListening("*");
	$.jStorage.flush();
});

test( "global/local listener order and flush calls listeners" , function(){
	$.jStorage.set("testkey1", "value");
	$.jStorage.set("testkey2", "value");
	let called = [];
	let f1 = function (key, action) { called.push([1, key, action]) };
	let f2 = function (key, action) { called.push([2, key, action]) };
	let f3 = function (key, action) { called.push([3, key, action]) };
	$.jStorage.listenKeyChange("*", f3);
	$.jStorage.listenKeyChange("testkey1", f1);
	$.jStorage.listenKeyChange("testkey2", f2);
	$.jStorage.flush();
	deepEqual(called, [[1, 'testkey1', 'deleted'],[3, 'testkey1', 'deleted'], [2, 'testkey2', 'deleted'], [3, 'testkey2', 'deleted']]);
	$.jStorage.stopListening("testkey1");
	$.jStorage.stopListening("testkey2");
	$.jStorage.stopListening("*");
	$.jStorage.flush();
});

asyncTest( "scheduled deletion due to ttl expiration" , function(){
	expect(4);
	let timeout;
	$.jStorage.set("testkey", "value", {TTL: 100});
	$.jStorage.listenKeyChange("testkey", function(key, action){
		strictEqual($.jStorage.get('testkey'), null);
		strictEqual(key, 'testkey');
		strictEqual(action, 'deleted');
		$.jStorage.stopListening("testkey");
		$.jStorage.flush();
		clearTimeout(timeout);
		start();
	});
	strictEqual($.jStorage.get('testkey'), 'value');
	timeout = setTimeout(function() {
		expect(2);
		ok(false, 'timed out');
		$.jStorage.flush();
		start();
	}, 350);
});

module("persistence");

asyncTest("page reload", function() {
	const f = document.createElement('iframe');
	document.body.append(f);
	let js = jStorage(true, {window:f.contentWindow});
	deepEqual(js.index(), []);
	js.set('testkey', 'testvalpagerel');
	f.contentWindow.location.reload();
	setTimeout(function() {
		js = jStorage(true, {window:f.contentWindow});
		strictEqual(js.get('testkey'), 'testvalpagerel');
		js.flush();
		f.remove();
		start();
	}, 250);
});

asyncTest("page location replace", function() {
	const f = document.createElement('iframe');
	document.body.append(f);
	let js = jStorage(true, {window:f.contentWindow});
	deepEqual(js.index(), []);
	js.set('testkey', 'testvallocrep');
	f.contentWindow.location.replace('about:blank');
	setTimeout(function() {
		js = jStorage(true, {window:f.contentWindow});
		strictEqual(js.get('testkey'), 'testvallocrep');
		js.flush();
		f.remove();
		start();
	}, 250);
});

asyncTest("page navigation - javascript", function() {
	const f = document.createElement('iframe');
	document.body.append(f);
	let js = jStorage(true, {window:f.contentWindow});
	deepEqual(js.index(), []);
	js.set('testkey', 'testvalnavjs');
	f.contentWindow.location = 'about:blank';
	setTimeout(function() {
		js = jStorage(true, {window:f.contentWindow});
		strictEqual(js.get('testkey'), 'testvalnavjs');
		js.flush();
		f.remove();
		start();
	}, 250);
});


asyncTest("page navigation - user", function() {
	const f = document.createElement('iframe');
	document.body.append(f);
	let js = jStorage(true, {window:f.contentWindow});
	deepEqual(js.index(), []);
	js.set('testkey', 'testvalnavuser');
	f.src = '';
	setTimeout(function() {
		js = jStorage(true, {window:f.contentWindow});
		strictEqual(js.get('testkey'), 'testvalnavuser');
		js.flush();
		f.remove();
		start();
	}, 250);
});

asyncTest("page close", function() {
	const w = window.open('about:blank');
	const storage = new DummyStorage();
	let js = jStorage(true, {window:objAssign(getBasicWindow(w.window), {sessionStorage:storage})});
	deepEqual(js.index(), []);
	js.set('testkey', 'testvalclose');
	console.log('closing');
	w.close();
	console.log('closed');
	setTimeout(function() {
		js = jStorage(true, {window:objAssign(getBasicWindow(window), {sessionStorage:storage})}); // window instead of w.window here because w.window can be null
		console.log(storage);
		strictEqual(js.get('testkey'), 'testvalclose');
		js.flush();
		start();
	}, 250);
});
All in One HTML Document Containing jStorage changes, QUnit Tests, and QUnit Test Runner
<!DOCTYPE html>
<html>
<!-- This file is distributed under the Apache 2.0 License -->
<head>
  <meta charset="utf-8">
  <title>jStorage &raquo; QUnit test runner</title>
  <link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.14.0.css">
</head>
<body>
  <div id="qunit"></div>
  <div id="qunit-fixture"></div>
  <script src="http://code.jquery.com/qunit/qunit-1.14.0.js"></script>
  <script>
  if (!window.$) {
	window.$ = {}
  }
  </script>
  <script>
	// New jStorage implementation
	function jStorage(localOnly, debugOptions) {
		'use strict';

		const realWindow = window;
		return function () { // IEF because we need to declare local window after accessing global
			let window;
			if (!debugOptions) {
				debugOptions = {};
			}
			if (debugOptions.window) {
				window = debugOptions.window;
			} else {
				window = realWindow;
			}

			const jStorage = {}; // the main jStorage object that exposes the API, shadows function name above
			const callbacks = {}; // callbacks for event listeners
			const backend = window.sessionStorage; // where the data ultimately gets stored
			const name = 'sessionStorage';
			const persistent = true; // whether backend is persistent
			const cache = {}; // in memory read cache / complete representation of the stored data
			let timeout = null; // timeout handle of timeout used to delete keys that have expired due to ttl
			let soonestExpiration = Infinity; // soonest expiration time of any key

			jStorage.version = 'lazyshallow-0.5.0';

			jStorage.storageAvailable = function () {return persistent};

			jStorage.currentBackend = function () {return name};

			jStorage.storageSize = function () {
				return JSON.stringify(jStorage._getOldFormat()).length;
			};

			jStorage.reInit = function () {
				if (backend.jStorage) {
					const obj = JSON.parse(backend.jStorage);
					const expiration = obj.__jstorage_meta.TTL;
					delete obj.__jstorage_meta;
					for (let key in obj) {
						if (obj.hasOwnProperty(key)) {
							const exp = expiration[key];
							cache[key] = [obj[key], exp ? exp : 0];
							this._scheduleCleanup(key, exp, true);
							// should not and does not fire observers
						}
					}
				}
			};

			jStorage.get = function (key, def) {
				this._checkKey(key);
				if (cache.hasOwnProperty(key)) {
					if (typeof cache[key][0] === 'object' && !Object.isExtensible(cache[key][0])) {
						cache[key][0] = deepcopy(cache[key][0]);
					}
					const val = cache[key][0];
					return val === undefined ? null : val;
				}
				return typeof(def) == 'undefined' ? null : def;
			};

			jStorage.getTTL = function (key) {
				this._checkKey(key);
				if (cache.hasOwnProperty(key)) {
					const ttl = cache[key][1];
					if (ttl) {
						return ttl - (+new Date());
					} else {
						return 0;
					}
				}
				return 0;
			};

			jStorage.deleteKey = function (key) {
				this._checkKey(key);
				if (cache.hasOwnProperty(key)) {
					delete cache[key];
					this._fireObservers(key, 'deleted');
					return true;
				}
				return false;
			};

			jStorage.flush = function () {
				for (let key in cache) {
					if (cache.hasOwnProperty(key)) {
						this.deleteKey(key);
					}
				}
				return true;
			};

			jStorage.index = function() {
				return Object.getOwnPropertyNames(cache);
			};

			jStorage.listenKeyChange = function (key, callback) {
				if (key !== '*') {
					this._checkKey(key);
				}
				if (!callbacks[key]) {
					callbacks[key] = [];
				}
				callbacks[key].push(callback);
			};

			jStorage.stopListening = function (key, callback) {
				if (key !== '*') {
					this._checkKey(key);
				}
				if (!callback) {
					delete callbacks[key];
				} else {
					if (callbacks[key]) {
						callbacks[key] = callbacks[key].filter(function (cb) { return cb !== callback });
					}
				}
			};

			jStorage.set = function (key, val, options) {
				if (!options) {
					options = {};
				}
				this._checkKey(key);
				if (val === null || val === undefined) {
					this.deleteKey(key);
					return val;
				} else {
					cache[key] = [val, 0];
					this.setTTL(key, options.TTL || 0);
					this._fireObservers(key, 'updated');
					return val;
				}
			};

			jStorage.setTTL = function (key, ttl) {
				this._checkKey(key);
				if (cache.hasOwnProperty(key)) {
					if (ttl && ttl > 0 ) {
						cache[key][1] = ttl + (+new Date());
					} else {
						cache[key][1] = 0;
					}
					this._scheduleCleanup(key, cache[key][1], false);
					return true;
				}
				return false;
			};

			jStorage.noConflict = function(saveInGlobal) {
				delete window.$.jStorage;
				if (saveInGlobal) {
					window.jStorage = this;
				}
				return this;
			};

			// dispatches event to listeners registered with listenKeyChange
			jStorage._fireObservers = function (keys, type) {
				keys = [].concat(keys || []);
				for (let i=0; i<keys.length; i++) {
					const key = keys[i];
					const matchingCallbacks = (callbacks[key] || []).concat(callbacks['*'] || []);
					for (let j=0; j<matchingCallbacks.length; j++) {
						matchingCallbacks[j](key, type, cache[key]);
					}
				}
			};

			// verify key is a valid key
			jStorage._checkKey = function (key) {
				if (typeof key != 'string' && typeof key != 'number') {
					throw new TypeError('Key name must be string or numeric');
				} else if (key === '*') {
					throw new TypeError('keyname/wildcard selector not allowed');
				} else if (key === '__jStorage_meta') {
					throw new TypeError('Reserved key name');
				}
				return true;
			};

			// schedules or reschedules the next TTL cleanup taking into account
			// the given expiration timestamp
			// If synchronous is true, the item will be cleaned up immediately if it
			// has already expired as of the start of the call.
			jStorage._scheduleCleanup = function(key, expiration, synchronous) {
				if (expiration === 0) {
					return;
				} else if (synchronous && expiration < +new Date()) {
					this.deleteKey(key);
				} else if (expiration < soonestExpiration) {
					soonestExpiration = expiration;
					clearTimeout(timeout);
					if (soonestExpiration < Infinity) {
						setTimeout(_cleanupTTL, soonestExpiration - (+new Date()));
					}
				}
			}

			// delete everything with expired ttl and schedule this to be called
			// again at the soonest expiration time of the remaining elements
			jStorage._cleanupTTL = function () {
				if (+new Date() > soonestExpiration) {
					for (let key in cache) {
						if (this.getTTL(key) < 0 && cache.hasOwnProperty(key)) {
							this.deleteKey(key);
						}
					}

					let newSoonest = Infinity
					for (let key in cache) {
						if (cache.hasOwnProperty(key) && cache[key][1] !== 0 && cache[key][1] < newSoonest) {
							newSoonest = cache[key][1];
						}
					}
					soonestExpiration = newSoonest;
				}
				if (soonestExpiration < Infinity) {
					timeout = setTimeout(_cleanupTTL, soonestExpiration - (+new Date()) );
				}
			};

			jStorage._getAll  = function() {
				const obj = {};
				for (let key in cache) {
					if (cache.hasOwnProperty(key)) {
						obj[key] = cache[key][0];
					}
				}
				return obj;
			};

			jStorage._getExpirations  = function() {
				const expirations = {};
				for (let key in cache) {
					if (cache[key][1] !== 0) {
						expirations[key] = cache[key][1];
					}
				}
				return expirations;
			};
			
			jStorage._getOldFormat = function () {
				const oldFormat = Object.assign( jStorage._getAll(), {"__jstorage_meta":{"CRC32":{}}});
				oldFormat.__jstorage_meta.TTL = jStorage._getExpirations();
				return oldFormat;
			}
			
				
			const deepcopy = function (obj) {
				const t = typeof obj;
				if (obj === undefined || obj === null || t === 'string' || t === 'number' || t === 'boolean') {
					return obj;
				} else if (typeof obj === 'object' && obj.toJSON === undefined) {
					if (Array.isArray(obj)) {
						const len = obj.length;
						const newArr = new Array(len);
						for (let i = 0; i < len; i++) {
							newArr[i] = deepcopy(obj[i]);
						}
						return newArr;
					} else {
						const newObj = {};
						for (let key in obj) {
							if (obj.hasOwnProperty(key)) {
								newObj[key] = deepcopy(obj[key]);
							}
						}
						return newObj;
					}
				}
				throw 'bad deep copy';
			};

			if (!String.prototype.startsWith) {
				Object.defineProperty(String.prototype, 'startsWith', {value: function(search, rawPos) {
					const pos = rawPos > 0 ? rawPos|0 : 0;
					return this.substring(pos, pos + search.length) === search;
				}});
			}
			
			if (!Object.prototype.assign) {
				Object.prototype.assign = function (target, source) {
					for (let prop in source) {
						if (source.hasOwnProperty(prop)) {
							target[prop] = source[prop]
						}
					}
					return target;
				}
			}

			if (typeof JSTORAGE_DEBUG_EXTENSIONS !== 'undefined' ) {
				JSTORAGE_DEBUG_EXTENSIONS(jStorage, debugOptions);
			}

			const _cleanupTTL = jStorage._cleanupTTL.bind(jStorage);

			// save changes back in format old jStorage can read
			window.document.addEventListener('visibilitychange', function() {
				backend.jStorage = JSON.stringify(jStorage._getOldFormat());
			});

			jStorage.reInit();

			if (!localOnly) {
				realWindow.$.jStorage = jStorage;
			}
			// cleanup scope
			if (!realWindow.JSTORAGE_DEBUG && realWindow.jStorage === jStorage) {
				delete realWindow.jStorage;
			}
			return jStorage;
		}();
	}

	jStorage();
  </script>
  <script>
	// QUnit Tests
	objAssign = function (target, source) {
		for (let prop in source) {
			if (source.hasOwnProperty(prop)) {
				target[prop] = source[prop]
			}
		}
		return target;
	}

	let stCalled = 0;
	let cbCalled = 0;
	let oldSetTimeout = window.setTimeout;
	window.setTimeout = function(callback, t) {
		stCalled++;
		return oldSetTimeout(function() {cbCalled++; return callback();}, t);
	};

	function TimeoutsMonitor() {	
		this.reset = function() {
			this.stCalledInitial = stCalled;
			this.cbCalledInitial = cbCalled;
		};
		
		this.timeouts = function() {
			return stCalled - this.stCalledInitial;
		};
		
		this.callbacks = function() {
			return cbCalled - this.cbCalledInitial;
		};
		
		this.tlt = function(ub) {
			return ok(this.timeouts() <= ub, '# calls to setTimeout: ' + this.timeouts());
		};
		
		this.cblt = function(ub) {
			return ok(this.callbacks() <= ub, '# callbacks by setTimeout: ' + this.callbacks());
		};	
		
		this.reset();
	}

	function getBasicWindow(window) {
		return {JSON:window.JSON, addEventListener:window.addEventListener.bind(window), document:{addEventListener:window.document.addEventListener.bind(window.document)}};
	}

	function DummyStorage() {
		Object.defineProperty(this, 'setItem', {enumerable: false, value: function (key, val) {this[key] = val} });
		Object.defineProperty(this, 'getItem', {enumerable: false, value: function (key, val) {return this[key]} });
		Object.defineProperty(this, 'removeItem', {enumerable: false, value: function (key, val) {delete this[key]} });
	}

	module( "test environment" );

	test( "test runner is allowed to open popup windows", function () {
		const w = window.open('about:blank');
		if (!w) {
			ok(false, 'you must configure your browser to allow popup windows in order for certain tests to successfully run');
		} else {
			ok(true);
			w.close();
		}
	});

	test( "sessionStorage exists in test environment", function () {
		ok(sessionStorage);
	});

	module( "browser behavior does not violate assumptions" );

	test( "sessionStorage is not bidirectionally synchronized across tabs", function() {
		const a = window.open('about:blank');
		const b = window.open('about:blank');
		a.window.sessionStorage.test = 'a';
		b.window.sessionStorage.test = 'b';
		window.sessionStorage.test = '';
		strictEqual(a.window.sessionStorage.test, 'a');
		strictEqual(b.window.sessionStorage.test, 'b');
		strictEqual(window.sessionStorage.test, '');
		a.close();
		b.close();
		strictEqual(window.sessionStorage.test, '');
	})

	module( "basic / misc functionality" );

	test( "flush/index", function() {
		strictEqual($.jStorage.flush(), true);
		deepEqual($.jStorage.index(), []);
		$.jStorage.set("test", "value");
		deepEqual($.jStorage.index(), ["test"]);
		strictEqual($.jStorage.flush(), true);
		deepEqual($.jStorage.index(), [])
		$.jStorage.set('test2', null);
		$.jStorage.set('test3', 'value');
		$.jStorage.set('test3', null);
		deepEqual($.jStorage.index(), []);
		strictEqual($.jStorage.get("test"), null);
		strictEqual($.jStorage.get("test2"), null);
		strictEqual($.jStorage.get("test3"), null);
		deepEqual($.jStorage.index(), []);
		//let js = jStorage(true);
		//deepEqual(js.index(), []);
		$.jStorage.flush();
	});

	test( "_getAll", function () {
		if ($.jStorage._getAll) {
			$.jStorage.flush();
			deepEqual($.jStorage._getAll(), {});
			$.jStorage.set('testkey', 'value');
			deepEqual($.jStorage._getAll(), {'testkey': 'value'});
			$.jStorage.flush();
			return;
		}
		expect(0);
	});

	test( "storageSize", function() {
		$.jStorage.flush();
		let size = $.jStorage.storageSize();
		ok(size <= 50);
		$.jStorage.set('k1', 'v1');
		ok($.jStorage.storageSize() > size);
		size = $.jStorage.storageSize();
		$.jStorage.set('k1', 'v1');
		equal($.jStorage.storageSize(), size);
		$.jStorage.set('k2', 'v2');
		ok($.jStorage.storageSize() > size);
		$.jStorage.flush();
	});

	module( "set" );

	test("undefined", function() {
		strictEqual($.jStorage.set("test", undefined), undefined);
		strictEqual($.jStorage.get("test"), null);
		$.jStorage.flush();
	});

	test("zero", function() {
		strictEqual($.jStorage.set("test", 0), 0);
		strictEqual($.jStorage.get("test"), 0);
		$.jStorage.flush();
	});

	test("missing", function() {
		strictEqual($.jStorage.get("test"), null);
		$.jStorage.flush();
	});

	test("use default", function() {
		$.jStorage.set("value exists", "value");
		strictEqual($.jStorage.get("no value", "def"), "def");
		strictEqual($.jStorage.get("value exists", "def"), "value");
		$.jStorage.flush();
	});

	test("string", function() {
		strictEqual($.jStorage.set("test", "value"), "value");
		strictEqual($.jStorage.get("test"), "value");
		$.jStorage.flush();
	});

	test("boolean", function() {
		strictEqual($.jStorage.set("test true", true), true);
		strictEqual($.jStorage.get("test true"), true);
		strictEqual($.jStorage.set("test false", false), false);
		strictEqual($.jStorage.get("test false"), false);
		$.jStorage.flush();
	});

	test("number", function() {
		strictEqual($.jStorage.set("test", 10.01), 10.01);
		strictEqual($.jStorage.get("test"), 10.01);
		strictEqual($.jStorage.set(3, "value"), "value");
		strictEqual($.jStorage.get(3), "value");
		$.jStorage.flush();
	});

	test("object", function() {
		let testObj  = {arr:[1,2,3], 4:5, true:6, false:7, 8:true, 9:false, 10:null, 11:0, 0:12};
		let original = {arr:[1,2,3], 4:5, true:6, false:7, 8:true, 9:false, 10:null, 11:0, 0:12};
		deepEqual($.jStorage.set("test", testObj), original);
		deepEqual(testObj, original);
		propEqual($.jStorage.get("test"), original);
		$.jStorage.flush();
	});

	module('delete');

	test("deleteKey", function() {
		deepEqual($.jStorage.index(), []);
		$.jStorage.set("test", "value");
		deepEqual($.jStorage.index(), ["test"]);
		strictEqual($.jStorage.deleteKey("test"), true);
		strictEqual($.jStorage.deleteKey("test"), false);
		deepEqual($.jStorage.index(), []);
		$.jStorage.flush();
	});

	module('ttl lifecycle');

	asyncTest("TTL", function() {
		expect(2);
		$.jStorage.set("ttlkey", "value", {TTL:500});
		setTimeout(function(){
			equal($.jStorage.get("ttlkey"), "value");
			setTimeout(function(){
				strictEqual($.jStorage.get("ttlkey"), null);
				$.jStorage.flush();
				start();
			}, 500);
		}, 250);
	});

	asyncTest("setTTL", function() {
		expect(2);
		$.jStorage.set("ttlkey", "value");
		$.jStorage.setTTL("ttlkey", 500);
		setTimeout(function(){
			equal($.jStorage.get("ttlkey"), "value");
			setTimeout(function(){
				strictEqual($.jStorage.get("ttlkey"), null);
				$.jStorage.flush();
				start();
			}, 500);
		}, 250);
	});

	asyncTest("setTTL - no expiration", function() {
		$.jStorage.set("ttlkey", "value");
		$.jStorage.setTTL("ttlkey", 0);
		setTimeout(function(){
			equal($.jStorage.get("ttlkey"), "value");
			$.jStorage.flush();
			start();
		}, 100);
	});

	test("setTTL - negative ttl", function() {
		$.jStorage.set("ttlkey", "value");
		strictEqual($.jStorage.get("ttlkey"), "value");
		$.jStorage.setTTL("ttlkey", -1);
		strictEqual($.jStorage.getTTL("ttlkey"), 0);
		strictEqual($.jStorage.get("ttlkey"), "value");
		$.jStorage.flush();
	});

	asyncTest("getTTL", function() {
		expect(3);
		$.jStorage.set("ttlkey", "value", {TTL: 500});
		setTimeout(function(){
			ok($.jStorage.getTTL("ttlkey") > 0);
			ok($.jStorage.getTTL("ttlkey") < 400);
			setTimeout(function(){
				strictEqual($.jStorage.getTTL("ttlkey"), 0);
				$.jStorage.flush();
				start();
			}, 500);
		}, 250);
	});

	asyncTest("getTTL - no expiration", function() {
		$.jStorage.set("ttlkey", "value", {TTL: 0});
		setTimeout(function(){
			equal($.jStorage.getTTL("ttlkey"), 0);
			$.jStorage.flush();
			start();
		}, 100);
	});

	asyncTest("increasing chain triggers okay", function() {
		expect(10);
		let tm = new TimeoutsMonitor();
		$.jStorage.set("ttlkey1", "value", {TTL:500});
		$.jStorage.set("ttlkey2", "value", {TTL:1000});
		strictEqual($.jStorage.get("ttlkey1"), "value");
		strictEqual($.jStorage.get("ttlkey2"), "value")
		setTimeout(function(){
			tm.cblt(5);
			tm.tlt(5);
			strictEqual($.jStorage.get("ttlkey1"), null);
			strictEqual($.jStorage.get("ttlkey2"), "value");
			setTimeout(function(){
				tm.cblt(10);
				tm.tlt(15);
				strictEqual($.jStorage.get("ttlkey1"), null);
				strictEqual($.jStorage.get("ttlkey2"), null);
				$.jStorage.flush();
				start();
			}, 500);
		}, 750);
	});

	asyncTest("decreasing chain triggers okay", function() {
		expect(10);
		let tm = new TimeoutsMonitor();
		$.jStorage.set("ttlkey1", "value", {TTL:1000});
		$.jStorage.set("ttlkey2", "value", {TTL:500});
		strictEqual($.jStorage.get("ttlkey1"), "value");
		strictEqual($.jStorage.get("ttlkey2"), "value")
		setTimeout(function(){		
			tm.cblt(5);
			tm.tlt(5);
			strictEqual($.jStorage.get("ttlkey1"), "value");
			strictEqual($.jStorage.get("ttlkey2"), null);
			setTimeout(function(){
				tm.cblt(10);
				tm.tlt(15)
				strictEqual($.jStorage.get("ttlkey1"), null);
				strictEqual($.jStorage.get("ttlkey2"), null);
				$.jStorage.flush();
				start();
			}, 500);
		}, 750);
	});

	module("listeners");

	asyncTest("stop listening - specific key", function() {
		expect(1);
		$.jStorage.listenKeyChange("testkey", function(key, action){
			console.error(key,action);
			$.jStorage.flush();
			ok(false);
			start();
		});
		$.jStorage.stopListening("testkey");
		
		$.jStorage.set("testkey", "value");
		setTimeout(function(){
			ok(true);
			$.jStorage.flush();
			start();
		}, 100);
	});

	test("specific key - updated", function() {
		let called = 0;
		$.jStorage.listenKeyChange("testkey", function(key, action){
			equal($.jStorage.get('testkey'), 'value');
			equal(key, "testkey");
			equal(action, "updated");
			called++;
		});
		equal(called, 0); // listener not yet called
		$.jStorage.set('testkey', 'value');
		equal(called, 1);
		$.jStorage.stopListening("testkey");
		$.jStorage.flush();
	});

	test("any key - updated", function() {
		let called = 0;
		$.jStorage.listenKeyChange("*", function(key, action){
			equal($.jStorage.get('testkey'), 'value');
			equal(key, "testkey");
			equal(action, "updated");
			called++;
		});
		equal(called, 0); // listener not yet called
		$.jStorage.set('testkey', 'value');
		equal(called, 1);
		$.jStorage.stopListening("*");
		$.jStorage.flush();
	});

	test("specific key - deleted", function() {
		$.jStorage.set("testkey", "value");
		let called = 0;
		$.jStorage.listenKeyChange("testkey", function(key, action){
			equal($.jStorage.get('testkey'), null);
			equal(key, "testkey");
			equal(action, "deleted");
			called++;
		});
		equal(called, 0); // listener not yet called
		$.jStorage.deleteKey("testkey");
		equal(called, 1);
		$.jStorage.stopListening("testkey");
		$.jStorage.flush();
	});

	test("any key - deleted", function() {
		$.jStorage.set("testkey", "value");
		let called = 0;
		$.jStorage.listenKeyChange("*", function(key, action){
			equal($.jStorage.get('testkey'), null);
			equal(key, "testkey");
			equal(action, "deleted");
			called++;
		});
		equal(called, 0); // listener not yet called
		$.jStorage.deleteKey("testkey");
		equal(called, 1);
		$.jStorage.stopListening("*");
		$.jStorage.flush();
	});

	test( "deleting nonexistant key" , function(){
		let called = 0;
		$.jStorage.listenKeyChange("testkey", function(key, action){
			called++;
		});
		$.jStorage.listenKeyChange("*", function(key, action){
			called++;
		});
		$.jStorage.deleteKey("testkey");
		equal(called, 0);
		$.jStorage.stopListening("testkey");
		$.jStorage.stopListening("*");
		$.jStorage.flush();
	});

	test( "setTTL" , function(){
		$.jStorage.set("testkey", "value");
		let called = 0;
		$.jStorage.listenKeyChange("testkey", function(key, action){
			called++;
		});
		$.jStorage.listenKeyChange("*", function(key, action){
			called++;
		});
		$.jStorage.setTTL("testkey", 500);
		equal(called, 0);
		$.jStorage.stopListening("testkey");
		$.jStorage.stopListening("*");
		$.jStorage.flush();
	});

	test( "global/local listener order and flush calls listeners" , function(){
		$.jStorage.set("testkey1", "value");
		$.jStorage.set("testkey2", "value");
		let called = [];
		let f1 = function (key, action) { called.push([1, key, action]) };
		let f2 = function (key, action) { called.push([2, key, action]) };
		let f3 = function (key, action) { called.push([3, key, action]) };
		$.jStorage.listenKeyChange("*", f3);
		$.jStorage.listenKeyChange("testkey1", f1);
		$.jStorage.listenKeyChange("testkey2", f2);
		$.jStorage.flush();
		deepEqual(called, [[1, 'testkey1', 'deleted'],[3, 'testkey1', 'deleted'], [2, 'testkey2', 'deleted'], [3, 'testkey2', 'deleted']]);
		$.jStorage.stopListening("testkey1");
		$.jStorage.stopListening("testkey2");
		$.jStorage.stopListening("*");
		$.jStorage.flush();
	});

	asyncTest( "scheduled deletion due to ttl expiration" , function(){
		expect(4);
		let timeout;
		$.jStorage.set("testkey", "value", {TTL: 100});
		$.jStorage.listenKeyChange("testkey", function(key, action){
			strictEqual($.jStorage.get('testkey'), null);
			strictEqual(key, 'testkey');
			strictEqual(action, 'deleted');
			$.jStorage.stopListening("testkey");
			$.jStorage.flush();
			clearTimeout(timeout);
			start();
		});
		strictEqual($.jStorage.get('testkey'), 'value');
		timeout = setTimeout(function() {
			expect(2);
			ok(false, 'timed out');
			$.jStorage.flush();
			start();
		}, 350);
	});

	module("persistence");

	asyncTest("page reload", function() {
		const f = document.createElement('iframe');
		document.body.append(f);
		let js = jStorage(true, {window:f.contentWindow});
		deepEqual(js.index(), []);
		js.set('testkey', 'testvalpagerel');
		f.contentWindow.location.reload();
		setTimeout(function() {
			js = jStorage(true, {window:f.contentWindow});
			strictEqual(js.get('testkey'), 'testvalpagerel');
			js.flush();
			f.remove();
			start();
		}, 250);
	});

	asyncTest("page location replace", function() {
		const f = document.createElement('iframe');
		document.body.append(f);
		let js = jStorage(true, {window:f.contentWindow});
		deepEqual(js.index(), []);
		js.set('testkey', 'testvallocrep');
		f.contentWindow.location.replace('about:blank');
		setTimeout(function() {
			js = jStorage(true, {window:f.contentWindow});
			strictEqual(js.get('testkey'), 'testvallocrep');
			js.flush();
			f.remove();
			start();
		}, 250);
	});

	asyncTest("page navigation - javascript", function() {
		const f = document.createElement('iframe');
		document.body.append(f);
		let js = jStorage(true, {window:f.contentWindow});
		deepEqual(js.index(), []);
		js.set('testkey', 'testvalnavjs');
		f.contentWindow.location = 'about:blank';
		setTimeout(function() {
			js = jStorage(true, {window:f.contentWindow});
			strictEqual(js.get('testkey'), 'testvalnavjs');
			js.flush();
			f.remove();
			start();
		}, 250);
	});


	asyncTest("page navigation - user", function() {
		const f = document.createElement('iframe');
		document.body.append(f);
		let js = jStorage(true, {window:f.contentWindow});
		deepEqual(js.index(), []);
		js.set('testkey', 'testvalnavuser');
		f.src = '';
		setTimeout(function() {
			js = jStorage(true, {window:f.contentWindow});
			strictEqual(js.get('testkey'), 'testvalnavuser');
			js.flush();
			f.remove();
			start();
		}, 250);
	});

	asyncTest("page close", function() {
		const w = window.open('about:blank');
		const storage = new DummyStorage();
		let js = jStorage(true, {window:objAssign(getBasicWindow(w.window), {sessionStorage:storage})});
		deepEqual(js.index(), []);
		js.set('testkey', 'testvalclose');
		console.log('closing');
		w.close();
		console.log('closed');
		setTimeout(function() {
			js = jStorage(true, {window:objAssign(getBasicWindow(window), {sessionStorage:storage})}); // window instead of w.window here because w.window can be null
			console.log(storage);
			strictEqual(js.get('testkey'), 'testvalclose');
			js.flush();
			start();
		}, 250);
	});
  </script>
</body>
</html>

[*] The implementation is designed to be 100% backwards compatible with the subset of features used and guarantees relied upon by WaniKani itself and common user scripts.
[**] Specifically, the time ends when the DOM finishes updating. Any tasks that happen after the DOM is finalized don’t count towards the measured time. This may result in an underestimate of the true latency of up to ~10ms.
[***] A deep copy is sometimes necessary during lessons, but only because WaniKani unecessarily freezes certain objects during lessons. (Freezing is also generally bad for performance.)
[****] To ensure a fair comparison, the change in the audio icon when auto playing audio in lessons was not included in measured latency.

7 Likes

Any thoughts as to why there seem to be two distinct groups of points in most of the columns?

In reviews without scripts (first three columns of first set of charts), the diagrams include separate data points for the initial submit of the answer (waiting for the answer bar to turn red/green) and moving on to the next question. The ordering is meaning initial submit → move on to reading for same item → reading initial submit → move on to meaning for next item. The two different lines in these charts roughly correspond to meaning initial submit and everything else. In the first column meaning initial submit is the higher line. In the second two columns, it is the lower line.

For lessons, changing to a completely new item takes a lot longer compared to switching lesson screens within the same item. This is true even if the “new” item is the same as the last one because there is only one item.

1 Like

I believe they are currently changing over to React. IIRC the lessons page is done, but they haven’t touched reviews yet?

1 Like

Great post!

Yes, the comment about using frameworks to avoid unnecessarily regenerating the page was more related to the reviews page where a decent amount of time is spent on that.

However, I think one of the reasons the lessons page is actually slower than reviews is that they may have not spent enough time optimizing their use of frameworks. From what I’ve read, if one wants React to avoid unnecessarily regenerating page elements, one often needs to override shouldComponentUpdate() which I’m not sure if they do. Also, they use (recently switched to using?) the Immer library in lessons in a configuration that is not recommended for production usage due to performance impact. (They turn the autofreeze option on.)

More broadly, in both lessons and reviews, in principle, they only need to update a few DOM elements on the page after each user action. I did some back of the envelope benchmarks for how much time DOM updates like those should take on their own and IIRC it was like <10ms for all of them total. Even taking into account framework overhead and the other things they need to do, I’m having trouble seeing why any of the things I benchmarked inherently need more than 20ms total.

3 Likes

QUnit tests have now been added for the fast jStorage implementation.

@est_fills_cando Thanks for doing all that benchmarking and writing up the examples. I don’t want to make any promises, but we have been thinking about a lot of these things, it’s just a matter of prioritization against other work and rolling out refactors safely. Looks like the examples you have are compatible with scripts, so it might be an easier change than I thought.

How are you generating the benchmarks and graphs?

5 Likes

Here is a more detailed description of the benchmarking procedure and copies of the scripts used for doing so. Although they were designed as UserScripts, the benchmarking scripts should probably work without changes if you wanted to just include them using a script tag in the document instead when testing.

For reviews, the benchmarks were recorded by injecting some javascript into the page that does the following for each of the scenarios tested:

  • takes a list of 600 real WaniKani review items from back when I had 600 pending reviews and replaces the activeQueue with the first 10 and reviewQueue with the remaining 590
  • intercepts ajax requests to the json/progress endpoint and doesn’t actually send those (so that benchmarking doesn’t affect my progress on WaniKani)
  • changes Math.random() to always return 0 so that the items encountered and order of the items is consistent between benchmarks
  • registers a MutationObserver with options {subtree:true, childList:true, attributes:true, characterData:true} to observe the #question element with a callback to store the value of performance.now() when the callback is called in a global variable called end (if the callback is called multiple times, only the value from the final call is used)
  • Does the following 40 times:
    1. sets a variable start to performance.now()
    2. set the value of #user-response to the first correct answer for the question if #user-response is not disabled
    3. calls the plain javascript (not jQuery) click() method of #answer-form button
    4. sleeps 500ms by awaiting the following async sleep function
      • function sleep(ms) { return new Promise(function (resolve, reject) { setTimeout(resolve, ms); }); }
    5. records end-start as the amount of latency
  • Since we do this 40 times, we get 40 data points which were then plotted in Excel. If you plan to do this yourself, you may want to use a javascript plotting library to avoid needing to repeatedly import things into Excel.
  • The Chrome devtools window was always kept closed during this process because I found opening it can make everything take a lot longer giving inaccurate results.
  • I always made sure to click on the page before the test starts running in order to allow audio to auto play because most browser block audio auto play if you haven’t interacted with the page yet.
  • This only measures the amount of time until the DOM finishes updating, which may be slightly less than the amount of time it takes from start until the updated DOM is actually rendered on the screen. When I profiled with Chrome devtools, this difference was usually less than 10ms, but sometimes as high as the mid teens. However, in my testing, things often run slower when the Chrome devtools window is open so the actual inaccuracy may be less than this. In any case, it’s much smaller than the improvements I observed and should not impact the accuracy of the jStorage-only tests relative to the baseline because it should be about the same in both of those tests. Only the tests that try to avoid style/layout recalculation would potentially have their performance artificially seem better compared to baseline due to this issue, but again, only by potentially at most ~10ms. You may be able to get a better idea of how large the inaccuracy due to this is by using Chrome’s profiler to compare the absolute amount of time spent after the DOM finishes updating in each scenario.

For lessons, a similar benchmarking approach was used with the following changes:

  • the lesson queue only had size 1 and I repeatedly scrolled through the lesson screens manually by pressing the right arrow key manually ~40 times (making sure to dismiss the “do you want to start the lesson quiz prompt”). Latency related to that prompt was not measured.
  • On the lessons page, the same MutationObserver options were used, but the observer was set to observe all of the following elements: #header, #supplement-nav, #batch-items.
  • The start time used was recorded by registering a capturing (not bubbling) event listener on document for the keydown event which sets start whenever a key is pressed. using something like document.addEventListener('keydown', function() { start = performance.now()}, true). Again, note that the final argument to addEventListener is true which makes it capturing, not bubbling.

Here is a UserScript implementing the automatic approach I used for benchmarking reviews described earlier. Note that it requires you to have a list of review items in the format returned by www.wanikani.com/review/queue stored as JSON-serialized data in localStorage['apf-testQueue'] and will fail with an error if this is not the case. Also, as mentioned, make sure to keep devtools closed since having it open will give inacurrate results. Results are stored in the global atimes variable. Clicking anywhere in the document is required to start the benchmark.

Automatic Benchmarking Script for Reviews
// ==UserScript==
// @name         Auto Perf Test
// @namespace    est_fills_cando
// @version      0.2
// @description  automatic performance benchmarking for WaniKani Reviews
// @author       est_fills_cando
// @match        https://www.wanikani.com/review/session
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    let queueSt = localStorage['apf-testQueue'];
    let queue = JSON.parse(queueSt);
    if (!queue)
        throw 'key apf-testQueue not found in localStorage';

    function main() {
        let oldXHROpen = window.XMLHttpRequest.prototype.open;
        window.XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
            this._url = url;
            this._method = method;
            this._async = async;
            return oldXHROpen.apply(this, arguments);
        }

        let oldXHRSend = window.XMLHttpRequest.prototype.send;
        window.XMLHttpRequest.prototype.send = function() {
            if (new URL(this._url, window.location.href).pathname !== '/json/progress') {
                return oldXHRSend.apply(this,arguments);
            }
        };

        /*
        let oldXHR = window.XMLHttpRequest;
        window.XMLHttpRequest = function () {
            let xhr = new oldXHR(...arguments);
            xhr.addEventListener('readystatechange', function() {
                if (new URL(xhr._url, window.location.href).pathname === '/review/queue') {
                    Object.defineProperty(this, 'response', {value:queueSt});
                    Object.defineProperty(this, 'responseText', {value:queueSt});
                }
            });
            return xhr;
        };
        */

        let end;
        let mo = new MutationObserver(function() {
            end = performance.now();
        });

        window.atimes = [];
        window.best = Infinity;
        document.addEventListener('click', async function () {
            $.jStorage.set('activeQueue', queue.slice(0,10));
            $.jStorage.set('reviewQueue', queue.slice(10));
            $.jStorage.set('currentItem', queue[0]);
            Math.random = function () { return 0 };
            await sleep(500);
            let target = document.querySelector('#question');
            mo.observe(target, {subtree:true, childList:true, attributes:true, characterData:true});
            for (let i=0; i<40; i++) {
                let ur =  document.querySelector('#user-response');
                if (!ur.disabled)
                    document.querySelector('#user-response').value = correctAnswer();
                let start = performance.now();
                document.querySelector('#answer-form button').click();
                await sleep(500);
                window.atimes.push(end-start);
                window.best = Math.min(window.best,end-start);
                console.log(end-start + ' ms');
            }
        });
    }

    let emphasisToKey = {kunyomi:'kun', nanori:'nanori', 'onyomi': 'on'};
    function correctAnswer() {
        let currentItem = $.jStorage.get('currentItem');
        let questionType = $.jStorage.get('questionType');
        if (questionType === 'meaning') {
            return currentItem.en[0];
        } else if (currentItem.kan) {
            return currentItem[emphasisToKey[currentItem.emph]][0];
        } else if (currentItem.voc) {
            return currentItem.kana[0];
        }
    }

    function sleep(ms) {
        return new Promise(function (resolve, reject) {
            setTimeout(resolve, ms);
        });
    }

    main();
})();

Here is a UserScript which implements the manual approach I used for benchmarking Lessons. It can also be used to do manual benchmarking in reviews, but is not recommended for reviews because it does not implement review order consistency, automatically submitting reviews, etc. Note that it takes a couple seconds to load and requires you to manually advance through lessons as mentioned. Benchmark results are displayed on the screen and saved in a global variable called mtimes.

Script for Manually Benchmarking Lessons/Reviews
// ==UserScript==
// @name         Perf Monitor
// @namespace    est_fills_cando
// @version      0.2
// @description  Performance monitor for WaniKani Lessons and reviews
// @author       est_fills_cando
// @match        https://www.wanikani.com/lesson/session
// @match        https://www.wanikani.com/review/session
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    let trackers = [];
    class Tracker {
        constructor(name, setupLate) {
            this.name = name
            this.info = document.createElement('div');
            this.begin = null;
            this.end= null;
            this.running = false;
            this.requested = false;
            this.setupLate = setupLate;
            this.methods = [this.start.bind(this), this.stop.bind(this), this.update.bind(this)];

            this.dataPoints = [];

            this.data = document.createElement('div');
            this.data.classList.add('perftest-data');

            this.caption = document.createElement('div');
            this.caption.textContent = name;

            this.info.append(this.data);
            this.info.append(this.caption);

            this.numShown = 15;
            this.cols = 5;

            this.padding = 5;

            trackers.push(this);
        }

        initLate() {
            this.info.style.padding = this.padding + 'px';
            this.info.style['font-size'] = '16px';
            this.info.style.background = 'white';
            this.data.style['text-align'] = 'right';
            this.data.style['display'] = 'grid';
            this.data.style['grid-template-columns'] = 'repeat(' + this.cols + ', 70px)';
            this.data.style.gap = '5px';
            for (let i=0; i<this.numShown; i++) {
                let dummy = document.createElement('div');
                dummy.textContent = '/';
                this.data.append(dummy);
            }

            this.caption.style = 'font-size: 16px; text-align: center';
            this.setupLate();

            this.boundSave = this.save.bind(this);
        }

        start() {
            this.requested = false;

            if (this.dataPoints.length === 0 || this.dataPoints[this.dataPoints.length - 1] !== null) {
                this.dataPoints.push(null);

                let el = document.createElement('span');
                el.textContent = '/';
                this.data.prepend(el);
                if (this.data.childNodes.length > this.numShown)
                    this.data.childNodes[this.data.childNodes.length - 1].remove();
            }
            this.running = true;
            this.begin = performance.now();
            this.end = this.begin;
        }

        stop() {
            this.running = false;
            this.begin = null;
            this.end = null;
            this.requested = false;
        }

        update() {
            if (this.running) {
                let oldEnd = this.end;
                this.end = performance.now();
                if (!this.requested) {
                    this.requested = true;
                    requestAnimationFrame( function () {
                        const duration = this.end - this.begin;
                        this.dataPoints[this.dataPoints.length - 1] = duration;
                        this.data.childNodes[0].textContent = duration.toFixed(0) + ' ms';

                        window.requestIdleCallback(this.boundSave);

                        this.requested = false;
                    }.bind(this));
                }
                return this.end - oldEnd;
            }
        }

        save() {
            //window.localStorage['wk-perftest-' + this.name] = JSON.stringify(this.dataPoints);
            window.mtimes = this.dataPoints.slice();
            if (window.mtimes[window.mtimes.length - 1] === null) {
                window.mtimes.pop();
            }
        }
    }

    function init_all() {
        let infos = document.createElement('div');
        infos.style.position = 'fixed';
        infos.style.left = 0;
        infos.style.top = 0;
        infos.style.display = 'flex';
        infos.style['flex-direction'] = 'column';
        infos.style.gap = '15px';
        infos.style['z-index'] = 9999;
        for (let tracker of trackers) {
            tracker.initLate();
            infos.append(tracker.info);
        }
        document.body.append(infos);
    }

    if (performance.timing.loadEventEnd)  {
        setTimeout(init_all, 1500);
    } else {
        window.addEventListener('load', function() {
            setTimeout(init_all, 1500);
        });
    }

    // keydown -> document change
    void function() {
        let tracker = new Tracker('keydown,mousedown -> last document change');
        let [start,stop,update] = tracker.methods;
        let startItem;
        let startType;
        document.addEventListener('keydown', start, true);
        document.addEventListener('mousedown', start, true);
        tracker.setupLate = function() {
            let mo = new MutationObserver(update);
            let options = {
                subtree: true,
                childList: true,
                attributes: true,
                characterData: true,
            };
            let targets = [[document.querySelector('#character'), {subtree:true,  childList: true}],
                           [document.querySelector('#question-type h1'), options],
                           [document.querySelector('#user-response'), {attributeFilter:['disabled']}],
                           [document.querySelector('#confusionGuesserOverlay'), options],
                           [document.querySelector('#header'), options],
                           [document.querySelector('#supplement-nav'), options],
                           [document.querySelector('#batch-items'), options],
                          ];
            for (let target of targets)
                if (target[0])
                    mo.observe(target[0], target[1]);
        };
    }();
})();

For the scenarios I benchmarked in my original post, the scenarios with improvements were simply using the “Load Faster” userscript near the end of my original post in this thread under the heading “UserScript that fixes some of these issues”. The jStorage ony tests had the first and last lines of main() in that script commented out. If you wanted to test the jStorage only changes, it is probably cleaner to simply replace the jStorage script you use with the one I provided under the heading “Just the jStorage changes”. One thing to be careful about is I noticed that your minified code for jStorage had some seemingly unrelated code related to Levenshtein distance appended after it on the same line, so make sure you don’t accidentally delete your Levenshtein distance code when replacing the jStorage code only.

Edit: For the changes in the proxy_jquery function of the “Load Faster” script, I would not recommend implementing those monkey patches verbatim but instead modifying your own code to obtain the same type of improvements those changes are realizing.

This topic was automatically closed 365 days after the last reply. New replies are no longer allowed.