diff --git a/packages/graphiql/src/components/QueryHistory.js b/packages/graphiql/src/components/QueryHistory.js index ea636d06e6c..8262ee6b656 100644 --- a/packages/graphiql/src/components/QueryHistory.js +++ b/packages/graphiql/src/components/QueryHistory.js @@ -11,6 +11,9 @@ import PropTypes from 'prop-types'; import QueryStore from '../utility/QueryStore'; import HistoryQuery from './HistoryQuery'; +const MAX_QUERY_SIZE = 100000; +const MAX_HISTORY_LENGTH = 20; + const shouldSaveQuery = (nextProps, current, lastQuerySaved) => { if (nextProps.queryID === current.queryID) { return false; @@ -20,6 +23,10 @@ const shouldSaveQuery = (nextProps, current, lastQuerySaved) => { } catch (e) { return false; } + // Don't try to save giant queries + if (nextProps.query.length > MAX_QUERY_SIZE) { + return false; + } if (!lastQuerySaved) { return true; } @@ -39,8 +46,6 @@ const shouldSaveQuery = (nextProps, current, lastQuerySaved) => { return true; }; -const MAX_HISTORY_LENGTH = 20; - export class QueryHistory extends React.Component { static propTypes = { query: PropTypes.string, @@ -53,8 +58,9 @@ export class QueryHistory extends React.Component { constructor(props) { super(props); - this.historyStore = new QueryStore('queries', props.storage); - this.favoriteStore = new QueryStore('favorites', props.storage); + this.historyStore = new QueryStore('queries', props.storage, MAX_HISTORY_LENGTH); + // favorites are not automatically deleted, so there's no need for a max length + this.favoriteStore = new QueryStore('favorites', props.storage, null); const historyQueries = this.historyStore.fetchAll(); const favoriteQueries = this.favoriteStore.fetchAll(); const queries = historyQueries.concat(favoriteQueries); @@ -71,9 +77,6 @@ export class QueryHistory extends React.Component { operationName: nextProps.operationName, }; this.historyStore.push(item); - if (this.historyStore.length > MAX_HISTORY_LENGTH) { - this.historyStore.shift(); - } const historyQueries = this.historyStore.items; const favoriteQueries = this.favoriteStore.items; const queries = historyQueries.concat(favoriteQueries); diff --git a/packages/graphiql/src/utility/QueryStore.js b/packages/graphiql/src/utility/QueryStore.js index fc30f4e3e28..cca050d4423 100644 --- a/packages/graphiql/src/utility/QueryStore.js +++ b/packages/graphiql/src/utility/QueryStore.js @@ -6,9 +6,10 @@ */ export default class QueryStore { - constructor(key, storage) { + constructor(key, storage, maxSize = null) { this.key = key; this.storage = storage; + this.maxSize = maxSize; this.items = this.fetchAll(); } @@ -64,13 +65,22 @@ export default class QueryStore { } push(item) { - this.items.push(item); - this.save(); - } + const items = [...this.items, item]; + + if (this.maxSize && items.length > this.maxSize) { + items.shift(); + } - shift() { - this.items.shift(); - this.save(); + for (let attempts = 0; attempts < 5; attempts++) { + const response = this.storage.set(this.key, JSON.stringify({ [this.key]: items }));; + if (!response || !response.error) { + this.items = items; + } else if (response.isQuotaError && this.maxSize) { // Only try to delete last items on LRU stores + items.shift(); + } else { + return; // We don't know what happened in this case, so just bailing out + } + } } save() { diff --git a/packages/graphiql/src/utility/StorageAPI.js b/packages/graphiql/src/utility/StorageAPI.js index 02f77cb708e..53c29a46ada 100644 --- a/packages/graphiql/src/utility/StorageAPI.js +++ b/packages/graphiql/src/utility/StorageAPI.js @@ -5,6 +5,23 @@ * LICENSE file in the root directory of this source tree. */ +function isQuotaError (storage, e) { + return ( + e instanceof DOMException && + // everything except Firefox + (e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === 'QuotaExceededError' || + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + // acknowledge QuotaExceededError only if there's something already stored + storage.length !== 0 + ); +} + export default class StorageAPI { constructor(storage) { this.storage = @@ -17,45 +34,37 @@ export default class StorageAPI { // Clean up any inadvertently saved null/undefined values. if (value === 'null' || value === 'undefined') { this.storage.removeItem('graphiql:' + name); - } else { - return value; + return null; } + + return value; } + + return null } set(name, value) { + let quotaError = false; + let error = null; + if (this.storage) { const key = `graphiql:${name}`; if (value) { - if (isStorageAvailable(this.storage, key, value)) { + try { this.storage.setItem(key, value); + } catch(e) { + error = e; + quotaError = isQuotaError(this.storage, e); } } else { // Clean up by removing the item if there's no value to set this.storage.removeItem(key); } } - } -} -function isStorageAvailable(storage, key, value) { - try { - storage.setItem(key, value); - return true; - } catch (e) { - return ( - e instanceof DOMException && - // everything except Firefox - (e.code === 22 || - // Firefox - e.code === 1014 || - // test name field too, because code might not be present - // everything except Firefox - e.name === 'QuotaExceededError' || - // Firefox - e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && - // acknowledge QuotaExceededError only if there's something already stored - storage.length !== 0 - ); + return { + isQuotaError: quotaError, + error + }; } } diff --git a/packages/graphiql/src/utility/__tests__/QueryStore.spec.js b/packages/graphiql/src/utility/__tests__/QueryStore.spec.js new file mode 100644 index 00000000000..a1ef7dda17a --- /dev/null +++ b/packages/graphiql/src/utility/__tests__/QueryStore.spec.js @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2019 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import QueryStore from '../QueryStore'; +import StorageAPI from '../StorageAPI'; + +class StorageMock { + constructor(shouldThrow) { + this.shouldThrow = shouldThrow; + this.map = {}; + } + + set (key, value) { + this.count++; + + if (this.shouldThrow()) { + return { + error: {}, + isQuotaError: true, + storageAvailable: true + }; + } + + this.map[key] = value; + + return { + error: null, + isQuotaError: false, + storageAvailable: true + }; + } + + get (key) { + return this.map[key] || null; + } +} + +describe('QueryStore', () => { + describe('with no max items', () => { + it('can push multiple items', () => { + const store = new QueryStore( + 'normal', + new StorageAPI() + ); + + for (let i = 0; i < 100; i++) { + store.push(`item${i}`); + } + + expect(store.items.length).toBe(100); + }); + + it('will fail silently on quota error', () => { + let i = 0; + const store = new QueryStore( + 'normal', + new StorageMock(() => i > 4) + ); + + for (; i < 10; i++) { + store.push(`item${i}`); + } + + expect(store.items.length).toBe(5); + expect(store.items[0]).toBe('item0'); + expect(store.items[4]).toBe('item4'); + }); + }); + + describe('with max items', () => { + it('can push a limited number of items', () => { + const store = new QueryStore( + 'limited', + new StorageAPI(), + 20 + ); + + for (let i = 0; i < 100; i++) { + store.push(`item${i}`); + } + + expect(store.items.length).toBe(20); + // keeps the more recent items + expect(store.items[0]).toBe('item80'); + expect(store.items[19]).toBe('item99'); + }); + + it('tries to remove on quota error until it succeeds', () => { + let shouldThrow; + let retryCounter = 0; + const store = new QueryStore( + 'normal', + new StorageMock(() => { + retryCounter++; + return shouldThrow(); + }), + 10 + ); + + for (let i = 0; i < 20; i++) { + shouldThrow = () => false; + store.push(`item${i}`); + } + + expect(store.items.length).toBe(10); + // keeps the more recent items + expect(store.items[0]).toBe('item10'); + expect(store.items[9]).toBe('item19'); + + // tries to add an item, succeeds on 3rd try + retryCounter = 0; + shouldThrow = () => retryCounter < 3; + store.push(`finalItem`); + + expect(store.items.length).toBe(8); + expect(store.items[0]).toBe('item13'); + expect(store.items[7]).toBe('finalItem'); + }); + + it('tries to remove a maximum of 5 times', () => { + let shouldTrow; + let retryCounter = 0; + const store = new QueryStore( + 'normal', + new StorageMock(() => { + retryCounter++; + return shouldTrow(); + }), + 10 + ); + + for (let i = 0; i < 20; i++) { + shouldTrow = () => false; + store.push(`item${i}`); + } + + expect(store.items.length).toBe(10); + // keeps the more recent items + expect(store.items[0]).toBe('item10'); + expect(store.items[9]).toBe('item19'); + + // tries to add an item, keeps failing + retryCounter = 0; + shouldTrow = () => true; + store.push(`finalItem`); + + expect(store.items.length).toBe(10); + // kept the items + expect(store.items[0]).toBe('item10'); + expect(store.items[9]).toBe('item19'); + // retried 5 times + expect(retryCounter).toBe(5); + }); + }); +}); diff --git a/packages/graphiql/src/utility/__tests__/StorageAPI.spec.js b/packages/graphiql/src/utility/__tests__/StorageAPI.spec.js new file mode 100644 index 00000000000..69bdf207e13 --- /dev/null +++ b/packages/graphiql/src/utility/__tests__/StorageAPI.spec.js @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2019 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import StorageAPI from '../StorageAPI'; + +describe('StorageAPI', () => { + const storage = new StorageAPI(); + + it('returns nothing if no value set', () => { + const result = storage.get('key1'); + expect(result).toBeNull(); + }); + + it('sets and gets a value correctly', () => { + let result = storage.set('key2', 'value'); + expect(result).toEqual({ + error: null, + isQuotaError: false + }); + + result = storage.get('key2'); + expect(result).toEqual('value'); + }); + + it('sets and removes a value correctly', () => { + let result = storage.set('key3', 'value'); + expect(result).toEqual({ + error: null, + isQuotaError: false + }); + + result = storage.set('key3'); + expect(result).toEqual({ + error: null, + isQuotaError: false + }); + + result = storage.get('key3'); + expect(result).toBeNull(); + }); + + it('sets and overrides a value correctly', () => { + let result = storage.set('key4', 'value'); + expect(result).toEqual({ + error: null, + isQuotaError: false + }); + + result = storage.set('key4', 'value2'); + expect(result).toEqual({ + error: null, + isQuotaError: false + }); + + result = storage.get('key4'); + expect(result).toEqual('value2'); + }); + + it('cleans up `null` value', () => { + storage.set('key5', 'null'); + const result = storage.get('key5'); + expect(result).toBeNull(); + }); + + it('cleans up `undefined` value', () => { + storage.set('key6', 'undefined'); + const result = storage.get('key6'); + expect(result).toBeNull(); + }); + + it('returns any error while setting a value', () => { + const throwingStorage = new StorageAPI({ + setItem: () => { throw new DOMException('Terrible Error'); }, + length: 1 + }); + const result = throwingStorage.set('key', 'value'); + + expect(result.error.message).toEqual('Terrible Error'); + expect(result.isQuotaError).toBe(false); + }); + + it('returns isQuotaError to true if isQuotaError is thrown', () => { + const throwingStorage = new StorageAPI({ + setItem: () => { + throw new DOMException('Terrible Error', 'QuotaExceededError'); + }, + length: 1 + }); + const result = throwingStorage.set('key', 'value'); + + expect(result.error.message).toEqual('Terrible Error'); + expect(result.isQuotaError).toBe(true); + }); +});