diff --git a/package.json b/package.json index 9f03ed3b0..8375048f4 100644 --- a/package.json +++ b/package.json @@ -98,15 +98,15 @@ "bundlesize": [ { "path": "packages/algoliasearch/dist/algoliasearch.umd.js", - "maxSize": "8KB" + "maxSize": "8.2KB" }, { "path": "packages/algoliasearch/dist/algoliasearch-lite.umd.js", - "maxSize": "4.4KB" + "maxSize": "4.6KB" }, { "path": "packages/recommend/dist/recommend.umd.js", - "maxSize": "4.2KB" + "maxSize": "4.3KB" } ] } diff --git a/packages/cache-browser-local-storage/src/__tests__/unit/browser-local-storage-cache.test.ts b/packages/cache-browser-local-storage/src/__tests__/unit/browser-local-storage-cache.test.ts index 70e90b8ef..1bef2d4e5 100644 --- a/packages/cache-browser-local-storage/src/__tests__/unit/browser-local-storage-cache.test.ts +++ b/packages/cache-browser-local-storage/src/__tests__/unit/browser-local-storage-cache.test.ts @@ -43,6 +43,23 @@ describe('browser local storage cache', () => { expect(missMock.mock.calls.length).toBe(1); }); + it('reads unexpired timeToLive keys', async () => { + const cache = createBrowserLocalStorageCache({ key: version, timeToLive: 5 }); + await cache.set({ key: 'foo' }, { bar: 1 }); + + const defaultValue = () => Promise.resolve({ bar: 2 }); + + const missMock = jest.fn(); + + expect( + await cache.get({ key: 'foo' }, defaultValue, { + miss: () => Promise.resolve(missMock()), + }) + ).toMatchObject({ bar: 1 }); + + expect(missMock.mock.calls.length).toBe(0); + }); + it('deletes keys', async () => { const cache = createBrowserLocalStorageCache({ key: version }); await cache.set({ key: 'foo' }, { bar: 1 }); @@ -62,6 +79,23 @@ describe('browser local storage cache', () => { expect(missMock.mock.calls.length).toBe(1); }); + it('deletes expired keys', async () => { + const cache = createBrowserLocalStorageCache({ key: version, timeToLive: -1 }); + await cache.set({ key: 'foo' }, { bar: 1 }); + + const defaultValue = () => Promise.resolve({ bar: 2 }); + + const missMock = jest.fn(); + + expect( + await cache.get({ key: 'foo' }, defaultValue, { + miss: () => Promise.resolve(missMock()), + }) + ).toMatchObject({ bar: 2 }); + + expect(missMock.mock.calls.length).toBe(1); + }); + it('can be cleared', async () => { const cache = createBrowserLocalStorageCache({ key: version }); await cache.set({ key: 'foo' }, { bar: 1 }); @@ -72,6 +106,8 @@ describe('browser local storage cache', () => { const missMock = jest.fn(); + expect(localStorage.length).toBe(0); + expect( await cache.get({ key: 'foo' }, defaultValue, { miss: () => Promise.resolve(missMock()), @@ -80,7 +116,7 @@ describe('browser local storage cache', () => { expect(missMock.mock.calls.length).toBe(1); - expect(localStorage.length).toBe(0); + expect(localStorage.getItem(`algoliasearch-client-js-${version}`)).toEqual('{}'); }); it('do throws localstorage exceptions on access', async () => { @@ -139,8 +175,15 @@ describe('browser local storage cache', () => { await cache.set(key, value); - expect(localStorage.getItem(`algoliasearch-client-js-${version}`)).toBe( - '{"{\\"foo\\":\\"bar\\"}":"foo"}' - ); + const expectedValue = expect.objectContaining({ + [JSON.stringify(key)]: { + timestamp: expect.any(Number), + value, + }, + }); + + const localStorageValue = localStorage.getItem(`algoliasearch-client-js-${version}`); + + expect(JSON.parse(localStorageValue ? localStorageValue : '{}')).toEqual(expectedValue); }); }); diff --git a/packages/cache-browser-local-storage/src/createBrowserLocalStorageCache.ts b/packages/cache-browser-local-storage/src/createBrowserLocalStorageCache.ts index 50738c413..1157bfc75 100644 --- a/packages/cache-browser-local-storage/src/createBrowserLocalStorageCache.ts +++ b/packages/cache-browser-local-storage/src/createBrowserLocalStorageCache.ts @@ -1,6 +1,6 @@ import { Cache, CacheEvents } from '@algolia/cache-common'; -import { BrowserLocalStorageOptions } from '.'; +import { BrowserLocalStorageCacheItem, BrowserLocalStorageOptions } from '.'; export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptions): Cache { const namespaceKey = `algoliasearch-client-js-${options.key}`; @@ -19,6 +19,36 @@ export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptio return JSON.parse(getStorage().getItem(namespaceKey) || '{}'); }; + const setNamespace = (namespace: Record) => { + getStorage().setItem(namespaceKey, JSON.stringify(namespace)); + }; + + const removeOutdatedCacheItems = () => { + const timeToLive = options.timeToLive ? options.timeToLive * 1000 : null; + const namespace = getNamespace(); + + const filteredNamespaceWithoutOldFormattedCacheItems = Object.fromEntries( + Object.entries(namespace).filter(([, cacheItem]) => { + return cacheItem.timestamp !== undefined; + }) + ); + + setNamespace(filteredNamespaceWithoutOldFormattedCacheItems); + + if (!timeToLive) return; + + const filteredNamespaceWithoutExpiredItems = Object.fromEntries( + Object.entries(filteredNamespaceWithoutOldFormattedCacheItems).filter(([, cacheItem]) => { + const currentTimestamp = new Date().getTime(); + const isExpired = cacheItem.timestamp + timeToLive < currentTimestamp; + + return !isExpired; + }) + ); + + setNamespace(filteredNamespaceWithoutExpiredItems); + }; + return { get( key: object | string, @@ -29,10 +59,14 @@ export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptio ): Readonly> { return Promise.resolve() .then(() => { + removeOutdatedCacheItems(); + const keyAsString = JSON.stringify(key); - const value = getNamespace()[keyAsString]; - return Promise.all([value || defaultValue(), value !== undefined]); + return getNamespace>()[keyAsString]; + }) + .then(value => { + return Promise.all([value ? value.value : defaultValue(), value !== undefined]); }) .then(([value, exists]) => { return Promise.all([value, exists || events.miss(value)]); @@ -45,7 +79,10 @@ export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptio const namespace = getNamespace(); // eslint-disable-next-line functional/immutable-data - namespace[JSON.stringify(key)] = value; + namespace[JSON.stringify(key)] = { + timestamp: new Date().getTime(), + value, + }; getStorage().setItem(namespaceKey, JSON.stringify(namespace)); diff --git a/packages/cache-browser-local-storage/src/types/BrowserLocalStorageCacheItem.ts b/packages/cache-browser-local-storage/src/types/BrowserLocalStorageCacheItem.ts new file mode 100644 index 000000000..5bcf432a6 --- /dev/null +++ b/packages/cache-browser-local-storage/src/types/BrowserLocalStorageCacheItem.ts @@ -0,0 +1,11 @@ +export type BrowserLocalStorageCacheItem = { + /** + * The cache item creation timestamp. + */ + readonly timestamp: number; + + /** + * The cache item value + */ + readonly value: any; +}; diff --git a/packages/cache-browser-local-storage/src/types/BrowserLocalStorageOptions.ts b/packages/cache-browser-local-storage/src/types/BrowserLocalStorageOptions.ts index 3652a4728..6a6e1dfda 100644 --- a/packages/cache-browser-local-storage/src/types/BrowserLocalStorageOptions.ts +++ b/packages/cache-browser-local-storage/src/types/BrowserLocalStorageOptions.ts @@ -4,6 +4,11 @@ export type BrowserLocalStorageOptions = { */ readonly key: string; + /** + * The time to live for each cached item in seconds. + */ + readonly timeToLive?: number; + /** * The native local storage implementation. */ diff --git a/packages/cache-browser-local-storage/src/types/index.ts b/packages/cache-browser-local-storage/src/types/index.ts index b8ae5a2ae..5b1b6940f 100644 --- a/packages/cache-browser-local-storage/src/types/index.ts +++ b/packages/cache-browser-local-storage/src/types/index.ts @@ -3,3 +3,4 @@ */ export * from './BrowserLocalStorageOptions'; +export * from './BrowserLocalStorageCacheItem';