diff --git a/index.d.ts b/index.d.ts index 8e44106..1c7581a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,6 +24,13 @@ declare module "@egjs/persist" { */ public remove(path: string): this; } + + export declare class PersistQuotaExceededError extends Error { + public name: string; + public storageType: "SessionStorage" | "LocalStorage" | "History" | "None"; + public key: string; + public size: number; + } export declare function updateDepth(type?: number): void; export default Persist; } diff --git a/src/Persist.js b/src/Persist.js index 6a2c131..7e6e1c1 100755 --- a/src/Persist.js +++ b/src/Persist.js @@ -5,7 +5,8 @@ import { getStateByKey, getStorage, } from "./storageManager"; -import {isNeeded, getUrl, getStorageKey, getNavigationType} from "./utils"; +import PersistQuotaExceededError from "./PersistQuotaExceededError"; +import {isNeeded, getUrl, getStorageKey, getNavigationType, isQuotaExceededError} from "./utils"; import {console, window} from "./browser"; import {TYPE_BACK_FORWARD, TYPE_NAVIGATE, CONST_PERSIST_STATE, CONST_DEPTHS, CONST_LAST_URL} from "./consts"; @@ -37,15 +38,12 @@ function setPersistState(key, value) { try { setStateByKey(CONST_PERSIST_STATE, key, value); } catch (e) { - if (clearFirst()) { + if (catchQuotaExceededError(e, CONST_PERSIST_STATE, value)) { if (key === CONST_LAST_URL) { setPersistState(key, value); } else if (key === CONST_DEPTHS) { setPersistState(key, value && value.slice(1)); } - } else { - // There is no more size to fit in. - throw e; } } } @@ -59,38 +57,56 @@ function updateDepth(type = 0) { return; } // url is not the same for the first time, pushState, or replaceState. - currentUrl = url; - const depths = getPersistState(CONST_DEPTHS) || []; + const prevUrl = currentUrl; - if (type === TYPE_BACK_FORWARD) { - // Change current url only - const currentIndex = depths.indexOf(currentUrl); + try { + currentUrl = url; + const depths = getPersistState(CONST_DEPTHS) || []; - ~currentIndex && setPersistState(CONST_LAST_URL, currentUrl); - } else { - const prevLastUrl = getPersistState(CONST_LAST_URL); + if (type === TYPE_BACK_FORWARD) { + // Change current url only + const currentIndex = depths.indexOf(url); + + ~currentIndex && setPersistState(CONST_LAST_URL, url); + } else { + const prevLastUrl = getPersistState(CONST_LAST_URL); - reset(getStorageKey(currentUrl)); + reset(getStorageKey(url)); - if (type === TYPE_NAVIGATE && url !== prevLastUrl) { - // Remove all url lists with higher index than current index - const prevLastIndex = depths.indexOf(prevLastUrl); - const removedList = depths.splice(prevLastIndex + 1, depths.length); + if (type === TYPE_NAVIGATE && url !== prevLastUrl) { + // Remove all url lists with higher index than current index + const prevLastIndex = depths.indexOf(prevLastUrl); + const removedList = depths.splice(prevLastIndex + 1, depths.length); - removedList.forEach(removedUrl => { - reset(getStorageKey(removedUrl)); - }); - // If the type is NAVIGATE and there is information about current url, delete it. - const currentIndex = depths.indexOf(currentUrl); + removedList.forEach(removedUrl => { + reset(getStorageKey(removedUrl)); + }); + // If the type is NAVIGATE and there is information about current url, delete it. + const currentIndex = depths.indexOf(url); - ~currentIndex && depths.splice(currentIndex, 1); - } - // Add depth for new address. - if (depths.indexOf(url) < 0) { - depths.push(url); + ~currentIndex && depths.splice(currentIndex, 1); + } + // Add depth for new address. + if (depths.indexOf(url) < 0) { + depths.push(url); + } + setPersistState(CONST_DEPTHS, depths); + setPersistState(CONST_LAST_URL, url); } - setPersistState(CONST_DEPTHS, depths); - setPersistState(CONST_LAST_URL, url); + } catch (e) { + // revert currentUrl + currentUrl = prevUrl; + throw e; + } +} + +function catchQuotaExceededError(e, key, value) { + if (clearFirst()) { + return true; + } else if (isQuotaExceededError(e)) { + throw new PersistQuotaExceededError(key, value ? JSON.stringify(value) : ""); + } else { + throw e; } } @@ -117,6 +133,7 @@ function clearFirst() { // Clear the previous record and try to add data again. return true; } + function clear() { const depths = getPersistState(CONST_DEPTHS) || []; @@ -128,12 +145,6 @@ function clear() { currentUrl = ""; } -if ("onpopstate" in window) { - window.addEventListener("popstate", () => { - // popstate event occurs when backward or forward - updateDepth(TYPE_BACK_FORWARD); - }); -} /** * Get or store the current state of the web page using JSON. @@ -184,7 +195,7 @@ class Persist { // find path const urlKey = getStorageKey(getUrl()); - const globalState = getStateByKey(urlKey, this.key); + const globalState = getStateByKey(urlKey, this.key); if (!path || path.length === 0) { @@ -221,7 +232,7 @@ class Persist { // find path const key = this.key; const urlKey = getStorageKey(getUrl()); - const globalState = getStateByKey(urlKey, key); + const globalState = getStateByKey(urlKey, key); try { if (path.length === 0) { @@ -238,11 +249,8 @@ class Persist { ); } } catch (e) { - if (clearFirst(e)) { + if (catchQuotaExceededError(e, urlKey, value)) { this.set(path, value); - } else { - // There is no more size to fit in. - throw e; } } return this; @@ -259,7 +267,7 @@ class Persist { // find path const key = this.key; const urlKey = getStorageKey(getUrl()); - const globalState = getStateByKey(urlKey, key); + const globalState = getStateByKey(urlKey, key); try { if (path.length === 0) { @@ -278,19 +286,38 @@ class Persist { ); } } catch (e) { - if (clearFirst(e)) { + if (catchQuotaExceededError(e)) { this.remove(path); - } else { - // There is no more size to fit in. - throw e; } } return this; } } + +if ("onpopstate" in window) { + window.addEventListener("popstate", () => { + // popstate event occurs when backward or forward + try { + updateDepth(TYPE_BACK_FORWARD); + } catch (e) { + // Global function calls prevent errors. + if (!isQuotaExceededError(e)) { + throw e; + } + } + }); +} + // If navigation's type is not TYPE_BACK_FORWARD, delete information about current url. -updateDepth(getNavigationType()); +try { + updateDepth(getNavigationType()); +} catch (e) { + // Global function calls prevent errors. + if (!isQuotaExceededError(e)) { + throw e; + } +} export { updateDepth, diff --git a/src/PersistQuotaExceededError.js b/src/PersistQuotaExceededError.js new file mode 100644 index 0000000..21aa09a --- /dev/null +++ b/src/PersistQuotaExceededError.js @@ -0,0 +1,69 @@ +import {getStorage, getStorageType} from "./storageManager"; + +const setPrototypeOf = Object.setPrototypeOf || ((obj, proto) => { + // eslint-disable-next-line no-proto + obj.__proto__ = proto; + return obj; +}); + + +/** + * Special type of known error that {@link Persist} throws. + * @ko Persist 내부에서 알려진 오류 발생시 throw되는 에러 + * @property {string} key Error key 에러가 되는 키 + * @property {string} message Error message 에러 메시지 + * @property {"SessionStorage" | "LocalStorage" | "History" | "None"} storageType The storage type in which the error occurred 에러가 발생한 스토리지 타입 + * @property {number} size The size of the value in which the error occurred 에러가 발생한 값의 사이즈 + * @property {Object} values Values of high size in storage. (maxLengh: 3) 스토리지의 높은 사이즈의 값들. (최대 3개) + * @example + * ```ts + * import Persist, { PersistQuotaExceededError } from "@egjs/persist"; + * try { + * const persist = new Persist("key"); + * } catch (e) { + * if (e instanceof PersistQuotaExceededError) { + * console.error("size", e.size); + * } + * } + * ``` + */ +class PersistQuotaExceededError extends Error { + /** + * @param key Error message에러 메시지 + * @param value Error value에러 값 + */ + constructor(key, value) { + const size = value.length; + const storageType = getStorageType(); + const storage = getStorage(); + let valuesText = ""; + let values = []; + + if (storage) { + const length = storage.length; + + for (let i = 0; i < length; ++i) { + const itemKey = storage.key(i); + const item = storage.getItem(itemKey) || ""; + + values.push({key: itemKey, size: item.length}); + } + values = values.sort((a, b) => b.size - a.size).slice(0, 3); + + if (values.length) { + valuesText = ` The highest values of ${storageType} are ${values.map(item => JSON.stringify({[item.key]: item.size})).join(", ")}.`; + } + } + + super(`Setting the value (size: ${size}) of '${key}' exceeded the ${storageType}'s quota.${valuesText}`); + + setPrototypeOf(this, PersistQuotaExceededError.prototype); + this.name = "PersistQuotaExceededError"; + this.storageType = storageType; + this.key = key; + this.size = size; + this.values = values; + } +} + +export default PersistQuotaExceededError; diff --git a/src/consts.js b/src/consts.js index 4404475..b3d61b0 100644 --- a/src/consts.js +++ b/src/consts.js @@ -1,4 +1,4 @@ -import {performance} from "./browser"; +import {performance, navigator, parseFloat} from "./browser"; export const CONST_PERSIST = "___persist___"; export const CONST_PERSIST_STATE = `state${CONST_PERSIST}`; @@ -9,3 +9,22 @@ const navigation = performance && performance.navigation; export const TYPE_NAVIGATE = (navigation && navigation.TYPE_NAVIGATE) || 0; export const TYPE_RELOAD = (navigation && navigation.TYPE_RELOAD) || 1; export const TYPE_BACK_FORWARD = (navigation && navigation.TYPE_BACK_FORWARD) || 2; + +const userAgent = navigator ? navigator.userAgent : ""; + +export const IS_PERSIST_NEEDED = (function() { + const isIOS = (new RegExp("iPhone|iPad", "i")).test(userAgent); + const isMacSafari = (new RegExp("Mac", "i")).test(userAgent) && + !(new RegExp("Chrome", "i")).test(userAgent) && + (new RegExp("Apple", "i")).test(userAgent); + const isAndroid = (new RegExp("Android ", "i")).test(userAgent); + const isWebview = (new RegExp("wv; |inapp;", "i")).test(userAgent); + const androidVersion = isAndroid ? parseFloat(new RegExp( + "(Android)\\s([\\d_\\.]+|\\d_0)", "i" + ).exec(userAgent)[2]) : undefined; + + return !(isIOS || + isMacSafari || + (isAndroid && + ((androidVersion <= 4.3 && isWebview) || androidVersion < 3))); +})(); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..ece1520 --- /dev/null +++ b/src/index.js @@ -0,0 +1,10 @@ +import Persist, {updateDepth} from "./Persist"; +import PersistQuotaExceededError from "./PersistQuotaExceededError"; + + +export { + updateDepth, + PersistQuotaExceededError, +}; + +export default Persist; diff --git a/src/index.umd.js b/src/index.umd.js index c00d15e..337d5c7 100755 --- a/src/index.umd.js +++ b/src/index.umd.js @@ -1,6 +1,7 @@ -import Persist, {updateDepth} from "./Persist"; +import Persist, * as modules from "./index"; -// eslint-disable-next-line import/no-named-as-default-member -Persist.updateDepth = updateDepth; +for (const name in modules) { + Persist[name] = modules[name]; +} export default Persist; diff --git a/src/storageManager.js b/src/storageManager.js index 7a03cab..38293dd 100644 --- a/src/storageManager.js +++ b/src/storageManager.js @@ -2,6 +2,7 @@ import {window, history, location, sessionStorage, localStorage} from "./browser import {CONST_PERSIST} from "./consts"; const isSupportState = history && "replaceState" in history && "state" in history; +let storageType = "None"; function isStorageAvailable(storage) { if (!storage) { @@ -26,8 +27,12 @@ const storage = (function() { if (isStorageAvailable(sessionStorage)) { strg = sessionStorage; + storageType = "SessionStorage"; } else if (isStorageAvailable(localStorage)) { strg = localStorage; + storageType = "LocalStorage"; + } else if (history.state) { + storageType = "History"; } return strg; @@ -40,10 +45,6 @@ function warnInvalidStorageValue() { /* eslint-enable no-console */ } -function getStorage() { - return storage; -} - /* * Get state value */ @@ -91,20 +92,6 @@ function getState(key) { return state; } -function getStateByKey(key, valueKey) { - if (!isSupportState && !storage) { - return undefined; - } - - let result = getState(key)[valueKey]; - - // some device returns "null" or undefined - if (result === "null" || typeof result === "undefined") { - result = null; - } - return result; -} - /* * Set state value */ @@ -142,7 +129,30 @@ function setState(key, state) { state ? window[CONST_PERSIST] = true : delete window[CONST_PERSIST]; } -function setStateByKey(key, valueKey, data) { + +export function getStorage() { + return storage; +} + +export function getStorageType() { + return storageType; +} + +export function getStateByKey(key, valueKey) { + if (!isSupportState && !storage) { + return undefined; + } + + let result = getState(key)[valueKey]; + + // some device returns "null" or undefined + if (result === "null" || typeof result === "undefined") { + result = null; + } + return result; +} + +export function setStateByKey(key, valueKey, data) { if (!isSupportState && !storage) { return; } @@ -156,13 +166,6 @@ function setStateByKey(key, valueKey, data) { /* * flush current history state */ -function reset(key) { +export function reset(key) { setState(key, null); } - -export { - reset, - setStateByKey, - getStateByKey, - getStorage, -}; diff --git a/src/utils.js b/src/utils.js index 527a171..fda2eff 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3,7 +3,7 @@ import {CONST_PERSIST} from "./consts"; const userAgent = navigator ? navigator.userAgent : ""; -const isNeeded = (function() { +export const isNeeded = (function() { const isIOS = (new RegExp("iPhone|iPad", "i")).test(userAgent); const isMacSafari = (new RegExp("Mac", "i")).test(userAgent) && !(new RegExp("Chrome", "i")).test(userAgent) && @@ -21,20 +21,19 @@ const isNeeded = (function() { })(); // In case of IE8, TYPE_BACK_FORWARD is undefined. -function getNavigationType() { +export function getNavigationType() { return performance && performance.navigation && performance.navigation.type; } -function getUrl() { + +export function getUrl() { return location ? location.href.split("#")[0] : ""; } -function getStorageKey(name) { + +export function getStorageKey(name) { return name + CONST_PERSIST; } -export { - getUrl, - getStorageKey, - getNavigationType, - isNeeded, -}; +export function isQuotaExceededError(e) { + return e.name === "QuotaExceededError" || e.name === "PersistQuotaExceededError"; +} diff --git a/test/manual/exceeded.html b/test/manual/exceeded.html new file mode 100755 index 0000000..14195f7 --- /dev/null +++ b/test/manual/exceeded.html @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/test/unit/TestHelper.js b/test/unit/TestHelper.js index 2b12f5f..eada5af 100644 --- a/test/unit/TestHelper.js +++ b/test/unit/TestHelper.js @@ -5,6 +5,7 @@ import {CONST_PERSIST_STATE, CONST_DEPTHS} from "../../src/consts"; import * as StorageManager from "../../src/storageManager"; import * as browser from "../../src/browser"; + const DEFAULT_HREF = location.href; export const INJECT_URL = "https://inject.com"; @@ -18,6 +19,14 @@ export function wait(time = 100) { }); } +export function throwQuotaExceedError() { + const err = new Error(`Failed to execute 'setItem' on 'Storage': Setting the value of 'URL' exceeded the quota.`); + + err.name = "QuotaExceededError"; + + throw err; +} + export function injectBrowser(href = DEFAULT_HREF) { const location = { pathname: INJECT_URL, @@ -67,6 +76,9 @@ export function injectBrowser(href = DEFAULT_HREF) { history, }; } +export function getDepths() { + return StorageManager.getStateByKey(CONST_PERSIST_STATE, CONST_DEPTHS) || []; +} export function sessionStorageForLimit(limit, limit2 = limit) { // Compare with limit when adding depth and limit2 when adding value. return { @@ -79,13 +91,13 @@ export function sessionStorageForLimit(limit, limit2 = limit) { if (key === CONST_PERSIST_STATE) { isExceed = JSON.parse(value || "{depths: []}")[CONST_DEPTHS].length > limit; } else { - isExceed = StorageManager.getStateByKey(CONST_PERSIST_STATE, CONST_DEPTHS).length > limit2; + isExceed = getDepths().length > limit2; } } catch (e) { } if (isExceed) { - throw new Error("exceed storage"); + throwQuotaExceedError(); } window.sessionStorage.setItem(key, value); }, diff --git a/test/unit/persist.spec.js b/test/unit/persist.spec.js index ce6fc47..15016bc 100755 --- a/test/unit/persist.spec.js +++ b/test/unit/persist.spec.js @@ -6,7 +6,8 @@ import Persist from "../../src/Persist"; import * as utils from "../../src/utils"; import * as StorageManager from "../../src/storageManager"; import {CONST_PERSIST_STATE, CONST_DEPTHS, CONST_LAST_URL} from "../../src/consts"; -import {wait, storageManagerForLimit, injectBrowser, injectPersistModules, INJECT_URL, injectPersist} from "./TestHelper"; +import {wait, storageManagerForLimit, injectBrowser, injectPersistModules, INJECT_URL, injectPersist, throwQuotaExceedError, getDepths} from "./TestHelper"; +import { PersistQuotaExceededError } from "../../src"; const StorageManagerUsingHistory = StorageManagerInjector( { @@ -605,6 +606,11 @@ describe("Persist", () => { const pathname = location.pathname; beforeEach(() => { + const length = sessionStorage.length; + + for (let i = 0; i < length; ++i) { + sessionStorage.removeItem(sessionStorage.key(i)); + } Persist.clear(); }); afterEach(() => { @@ -612,10 +618,9 @@ describe("Persist", () => { }); it(`test depth test for exceed test (depths limit: 0)`, () => { - // Given try { - // When - new(injectPersist( + // Given + const persist = new(injectPersist( { "./storageManager": storageManagerForLimit(0), "./browser": { @@ -624,33 +629,46 @@ describe("Persist", () => { }, } ))(""); + + // When + persist.set("a", ""); } catch (e) { // Then // An unconditional error occurs. - expect(e.message).to.be.equals("exceed storage"); + expect(e).to.be.an.instanceof(PersistQuotaExceededError); return; } throw new Error("Errors should occur unconditionally, but they ignored them."); }); - it(`test depth test for exceed test (depths limit: 1, value limit: 0)`, () => { - // Given - const persist = new(injectPersist( - { - "./storageManager": storageManagerForLimit(1, 0), - "./browser": { - window: {}, - console, - }, - } - ))(""); - - // When + it(`should check if other values of sessionStorage are displayed`, () => { try { - persist.set("a", 1); + // Given + const persist = new(injectPersist( + { + "./storageManager": storageManagerForLimit(0), + "./browser": { + window: {}, + console: window, + }, + } + ))(""); + + sessionStorage.setItem("test1", "22"); + sessionStorage.setItem("test2", "1114"); + + // When + persist.set("a", "1"); } catch (e) { // Then - // An unconditional error occurs. - expect(e.message).to.be.equals("exceed storage"); + expect(e).to.be.an.instanceof(PersistQuotaExceededError); + expect(e.message).to.have.string("test1"); + expect(e.message).to.have.string("test2"); + // 0: tmp__state__ + expect(e.values[1].key).to.be.equals("test2"); + expect(e.values[1].size).to.be.equals(4); + + expect(e.values[2].key).to.be.equals("test1"); + expect(e.values[2].size).to.be.equals(2); return; } throw new Error("Errors should occur unconditionally, but they ignored them."); @@ -675,7 +693,7 @@ describe("Persist", () => { persist.set("a", "1"); // Then - const state1 = StorageManager.getStateByKey(CONST_PERSIST_STATE, CONST_DEPTHS).length; + const state1 = getDepths().length; expect(state1).to.be.equals(limit - 1); @@ -684,7 +702,7 @@ describe("Persist", () => { persist.set("a", "1"); // Then - const state2 = StorageManager.getStateByKey(CONST_PERSIST_STATE, CONST_DEPTHS).length; + const state2 = getDepths().length; expect(state2).to.be.equals(limit - 1); } else { @@ -698,7 +716,7 @@ describe("Persist", () => { // start, a0 // start, a0 , a1 // a0 , a1, a2 - const state = StorageManager.getStateByKey(CONST_PERSIST_STATE, CONST_DEPTHS).length; + const state = getDepths().length; expect(state).to.be.equals(j + 2); } @@ -710,7 +728,7 @@ describe("Persist", () => { persist.set("a", 1); // Then - const currentState = StorageManager.getStateByKey(CONST_PERSIST_STATE, CONST_DEPTHS).length; + const currentState = getDepths().length; expect(currentState).to.be.equals(limit - 1); }