Skip to content

Commit 9092414

Browse files
feat(cache-browser-local-storage): Implemented TTL support to cached items (#1457)
1 parent a5c6a64 commit 9092414

File tree

6 files changed

+108
-11
lines changed

6 files changed

+108
-11
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,15 @@
9898
"bundlesize": [
9999
{
100100
"path": "packages/algoliasearch/dist/algoliasearch.umd.js",
101-
"maxSize": "8KB"
101+
"maxSize": "8.2KB"
102102
},
103103
{
104104
"path": "packages/algoliasearch/dist/algoliasearch-lite.umd.js",
105-
"maxSize": "4.4KB"
105+
"maxSize": "4.6KB"
106106
},
107107
{
108108
"path": "packages/recommend/dist/recommend.umd.js",
109-
"maxSize": "4.2KB"
109+
"maxSize": "4.3KB"
110110
}
111111
]
112112
}

packages/cache-browser-local-storage/src/__tests__/unit/browser-local-storage-cache.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ describe('browser local storage cache', () => {
4343
expect(missMock.mock.calls.length).toBe(1);
4444
});
4545

46+
it('reads unexpired timeToLive keys', async () => {
47+
const cache = createBrowserLocalStorageCache({ key: version, timeToLive: 5 });
48+
await cache.set({ key: 'foo' }, { bar: 1 });
49+
50+
const defaultValue = () => Promise.resolve({ bar: 2 });
51+
52+
const missMock = jest.fn();
53+
54+
expect(
55+
await cache.get({ key: 'foo' }, defaultValue, {
56+
miss: () => Promise.resolve(missMock()),
57+
})
58+
).toMatchObject({ bar: 1 });
59+
60+
expect(missMock.mock.calls.length).toBe(0);
61+
});
62+
4663
it('deletes keys', async () => {
4764
const cache = createBrowserLocalStorageCache({ key: version });
4865
await cache.set({ key: 'foo' }, { bar: 1 });
@@ -62,6 +79,23 @@ describe('browser local storage cache', () => {
6279
expect(missMock.mock.calls.length).toBe(1);
6380
});
6481

82+
it('deletes expired keys', async () => {
83+
const cache = createBrowserLocalStorageCache({ key: version, timeToLive: -1 });
84+
await cache.set({ key: 'foo' }, { bar: 1 });
85+
86+
const defaultValue = () => Promise.resolve({ bar: 2 });
87+
88+
const missMock = jest.fn();
89+
90+
expect(
91+
await cache.get({ key: 'foo' }, defaultValue, {
92+
miss: () => Promise.resolve(missMock()),
93+
})
94+
).toMatchObject({ bar: 2 });
95+
96+
expect(missMock.mock.calls.length).toBe(1);
97+
});
98+
6599
it('can be cleared', async () => {
66100
const cache = createBrowserLocalStorageCache({ key: version });
67101
await cache.set({ key: 'foo' }, { bar: 1 });
@@ -72,6 +106,8 @@ describe('browser local storage cache', () => {
72106

73107
const missMock = jest.fn();
74108

109+
expect(localStorage.length).toBe(0);
110+
75111
expect(
76112
await cache.get({ key: 'foo' }, defaultValue, {
77113
miss: () => Promise.resolve(missMock()),
@@ -80,7 +116,7 @@ describe('browser local storage cache', () => {
80116

81117
expect(missMock.mock.calls.length).toBe(1);
82118

83-
expect(localStorage.length).toBe(0);
119+
expect(localStorage.getItem(`algoliasearch-client-js-${version}`)).toEqual('{}');
84120
});
85121

86122
it('do throws localstorage exceptions on access', async () => {
@@ -139,8 +175,15 @@ describe('browser local storage cache', () => {
139175

140176
await cache.set(key, value);
141177

142-
expect(localStorage.getItem(`algoliasearch-client-js-${version}`)).toBe(
143-
'{"{\\"foo\\":\\"bar\\"}":"foo"}'
144-
);
178+
const expectedValue = expect.objectContaining({
179+
[JSON.stringify(key)]: {
180+
timestamp: expect.any(Number),
181+
value,
182+
},
183+
});
184+
185+
const localStorageValue = localStorage.getItem(`algoliasearch-client-js-${version}`);
186+
187+
expect(JSON.parse(localStorageValue ? localStorageValue : '{}')).toEqual(expectedValue);
145188
});
146189
});

packages/cache-browser-local-storage/src/createBrowserLocalStorageCache.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Cache, CacheEvents } from '@algolia/cache-common';
22

3-
import { BrowserLocalStorageOptions } from '.';
3+
import { BrowserLocalStorageCacheItem, BrowserLocalStorageOptions } from '.';
44

55
export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptions): Cache {
66
const namespaceKey = `algoliasearch-client-js-${options.key}`;
@@ -19,6 +19,36 @@ export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptio
1919
return JSON.parse(getStorage().getItem(namespaceKey) || '{}');
2020
};
2121

22+
const setNamespace = (namespace: Record<string, any>) => {
23+
getStorage().setItem(namespaceKey, JSON.stringify(namespace));
24+
};
25+
26+
const removeOutdatedCacheItems = () => {
27+
const timeToLive = options.timeToLive ? options.timeToLive * 1000 : null;
28+
const namespace = getNamespace<BrowserLocalStorageCacheItem>();
29+
30+
const filteredNamespaceWithoutOldFormattedCacheItems = Object.fromEntries(
31+
Object.entries(namespace).filter(([, cacheItem]) => {
32+
return cacheItem.timestamp !== undefined;
33+
})
34+
);
35+
36+
setNamespace(filteredNamespaceWithoutOldFormattedCacheItems);
37+
38+
if (!timeToLive) return;
39+
40+
const filteredNamespaceWithoutExpiredItems = Object.fromEntries(
41+
Object.entries(filteredNamespaceWithoutOldFormattedCacheItems).filter(([, cacheItem]) => {
42+
const currentTimestamp = new Date().getTime();
43+
const isExpired = cacheItem.timestamp + timeToLive < currentTimestamp;
44+
45+
return !isExpired;
46+
})
47+
);
48+
49+
setNamespace(filteredNamespaceWithoutExpiredItems);
50+
};
51+
2252
return {
2353
get<TValue>(
2454
key: object | string,
@@ -29,10 +59,14 @@ export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptio
2959
): Readonly<Promise<TValue>> {
3060
return Promise.resolve()
3161
.then(() => {
62+
removeOutdatedCacheItems();
63+
3264
const keyAsString = JSON.stringify(key);
33-
const value = getNamespace<TValue>()[keyAsString];
3465

35-
return Promise.all([value || defaultValue(), value !== undefined]);
66+
return getNamespace<Promise<BrowserLocalStorageCacheItem>>()[keyAsString];
67+
})
68+
.then(value => {
69+
return Promise.all([value ? value.value : defaultValue(), value !== undefined]);
3670
})
3771
.then(([value, exists]) => {
3872
return Promise.all([value, exists || events.miss(value)]);
@@ -45,7 +79,10 @@ export function createBrowserLocalStorageCache(options: BrowserLocalStorageOptio
4579
const namespace = getNamespace();
4680

4781
// eslint-disable-next-line functional/immutable-data
48-
namespace[JSON.stringify(key)] = value;
82+
namespace[JSON.stringify(key)] = {
83+
timestamp: new Date().getTime(),
84+
value,
85+
};
4986

5087
getStorage().setItem(namespaceKey, JSON.stringify(namespace));
5188

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export type BrowserLocalStorageCacheItem = {
2+
/**
3+
* The cache item creation timestamp.
4+
*/
5+
readonly timestamp: number;
6+
7+
/**
8+
* The cache item value
9+
*/
10+
readonly value: any;
11+
};

packages/cache-browser-local-storage/src/types/BrowserLocalStorageOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export type BrowserLocalStorageOptions = {
44
*/
55
readonly key: string;
66

7+
/**
8+
* The time to live for each cached item in seconds.
9+
*/
10+
readonly timeToLive?: number;
11+
712
/**
813
* The native local storage implementation.
914
*/

packages/cache-browser-local-storage/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
*/
44

55
export * from './BrowserLocalStorageOptions';
6+
export * from './BrowserLocalStorageCacheItem';

0 commit comments

Comments
 (0)