Skip to content

Commit db0a60d

Browse files
committed
fix(storage): handle variable quota size errors
1 parent 101ee1e commit db0a60d

File tree

3 files changed

+76
-19
lines changed

3 files changed

+76
-19
lines changed

src/stores/data/image.store.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { ImagePayload } from '~/models/poster.model';
1010
import { ErrorService } from '~/services/error.service';
1111
import { TraktService } from '~/services/trakt.service';
1212
import { logger } from '~/stores/settings/log.store';
13-
import { localCache, storage } from '~/utils/browser/browser-storage.utils';
13+
import { setStorageWrapper, storage } from '~/utils/browser/browser-storage.utils';
1414
import { getShortLocale } from '~/utils/browser/browser.utils';
1515
import { CachePrefix } from '~/utils/cache.utils';
1616
import { debounce } from '~/utils/debounce.utils';
@@ -94,11 +94,11 @@ export const useImageStore = defineStore(ImageStoreConstants.Store, () => {
9494
const saveState = debounce(
9595
(_images = images) =>
9696
Promise.all([
97-
localCache(ImageStoreConstants.LocalMovie, _images.movie, CachePrefix.Tmdb),
98-
localCache(ImageStoreConstants.LocalShow, _images.show, CachePrefix.Tmdb),
99-
localCache(ImageStoreConstants.LocalSeason, _images.season, CachePrefix.Tmdb),
100-
localCache(ImageStoreConstants.LocalEpisode, _images.episode, CachePrefix.Tmdb),
101-
localCache(ImageStoreConstants.LocalPerson, _images.person, CachePrefix.Tmdb),
97+
setStorageWrapper(ImageStoreConstants.LocalMovie, _images.movie, CachePrefix.Tmdb),
98+
setStorageWrapper(ImageStoreConstants.LocalShow, _images.show, CachePrefix.Tmdb),
99+
setStorageWrapper(ImageStoreConstants.LocalSeason, _images.season, CachePrefix.Tmdb),
100+
setStorageWrapper(ImageStoreConstants.LocalEpisode, _images.episode, CachePrefix.Tmdb),
101+
setStorageWrapper(ImageStoreConstants.LocalPerson, _images.person, CachePrefix.Tmdb),
102102
]),
103103
1000,
104104
);

src/utils/browser/browser-storage.utils.ts

+68-11
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,23 @@ const getLocalStorageSize = (storage = window.localStorage, encoder = new TextEn
3030
return Object.entries(storage).reduce((acc, [key, value]) => acc + encoder.encode(key).length + encoder.encode(value).length, 0);
3131
};
3232

33+
export type StorageAreaWrapper = {
34+
name: string;
35+
getBytesInUse: () => Promise<number>;
36+
getAll: <T>(regex?: string | RegExp) => Promise<T>;
37+
get: <T>(key: string) => Promise<T>;
38+
set: <T>(key: string, value: T) => Promise<void>;
39+
remove: (key: string) => Promise<void>;
40+
removeAll: (regex: string | RegExp) => Promise<void>;
41+
clear: () => Promise<void>;
42+
};
43+
3344
/**
3445
* This function is used to wrap the storage areas to provide type inference and a more convenient interface.
3546
* @param area The storage area to wrap.
3647
* @param name The name of the storage area.
3748
*/
38-
export const storageWrapper = (area: chrome.storage.StorageArea, name: string) => {
49+
export const storageWrapper = (area: chrome.storage.StorageArea, name: string): StorageAreaWrapper => {
3950
if (!globalThis?.chrome?.storage) {
4051
console.warn('Storage API is not available, using local storage instead.');
4152

@@ -62,6 +73,7 @@ export const storageWrapper = (area: chrome.storage.StorageArea, name: string) =
6273

6374
window.trakt = { ...window.trakt, [name]: storage };
6475
return {
76+
name,
6577
getBytesInUse: async (): Promise<number> => getLocalStorageSize(window.localStorage),
6678
getAll: async <T>(regex?: string | RegExp): Promise<T> => (regex ? filterObject(storage.values, regex) : storage.values) as T,
6779
get: async <T>(key: string): Promise<T> => storage.values[key] as T,
@@ -74,6 +86,7 @@ export const storageWrapper = (area: chrome.storage.StorageArea, name: string) =
7486
};
7587
}
7688
return {
89+
name,
7790
getBytesInUse: (): Promise<number> => area.getBytesInUse(),
7891
getAll: <T>(regex?: string | RegExp): Promise<T> => area.get().then(data => (regex ? filterObject(data, regex) : data) as T),
7992
get: <T>(key: string): Promise<T> => area.get(key).then(({ [key]: value }) => value),
@@ -104,32 +117,76 @@ export const storage = {
104117
session: storageWrapper(sessionStorage, 'session'),
105118
};
106119

107-
export const defaultMaxLocalStorageSize = 10485760;
120+
/**
121+
* Determines whether an error is a QuotaExceededError.
122+
*
123+
* Browsers love throwing slightly different variations of QuotaExceededError
124+
* (this is especially true for old browsers/versions), so we need to check
125+
* different fields and values to ensure we cover every edge-case.
126+
*
127+
* @param err - The error to check
128+
* @return Is the error a QuotaExceededError?
129+
*/
130+
export const isQuotaExceededError = (err: unknown): boolean => {
131+
if (!(err instanceof DOMException)) return false;
132+
if (err.name === 'QuotaExceededError') return true;
133+
if (err.name === 'NS_ERROR_DOM_QUOTA_REACHED') return true; // Firefox
134+
if (err.code === 22) return true;
135+
return err.code === 1014; // Firefox
136+
};
108137

109-
export const localCache: <T>(key: string, value: T, regex?: string | RegExp) => Promise<void> = async (key, value, regex) => {
110-
let inUse = await storage.local.getBytesInUse();
138+
/**
139+
* The default maximum size of the local/session storage.
140+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria
141+
*/
142+
export const defaultMaxLocalStorageSize = 10485760 / 2;
143+
144+
/**
145+
* Wrapper around the storage set method with additional checks for the size of the storage and storage eviction.
146+
* @param key The key to store the value under.
147+
* @param value The payload to store.
148+
* @param regex An optional regex to filter the keys to remove when the storage is full.
149+
* @param area The storage area to use.
150+
*/
151+
export const setStorageWrapper: <T>(key: string, value: T, regex?: string | RegExp, area?: StorageAreaWrapper) => Promise<void> = async (
152+
key,
153+
value,
154+
regex,
155+
area = storage.local,
156+
) => {
157+
let inUse = await area.getBytesInUse();
111158

112159
const max = globalThis?.chrome?.storage?.local.QUOTA_BYTES ?? defaultMaxLocalStorageSize;
113160

114161
const encoder = new TextEncoder();
115162
const payload = encoder.encode(JSON.stringify(value)).length;
116163

117164
if (payload > max) {
118-
console.warn('Payload is too large to store in local storage.', { payload, max, inUse });
165+
console.warn('Payload is too large to store in local storage.', { payload, max, inUse, regex, area: area.name });
119166
return Promise.resolve();
120167
}
121168

122169
if (inUse + payload >= max) {
123-
console.warn('Local storage is full, clearing cache.', { payload, max, inUse });
124-
if (regex) await storage.local.removeAll(regex);
125-
else await storage.local.clear();
170+
console.warn('Local storage is full, clearing cache.', { payload, max, inUse, regex, area: area.name });
171+
if (regex) await area.removeAll(regex);
172+
else await area.clear();
126173
}
127174

128-
inUse = await storage.local.getBytesInUse();
175+
inUse = await area.getBytesInUse();
129176
if (inUse + payload >= max) {
130-
console.warn('Local storage is still full, skipping cache.', { payload, max, inUse });
177+
console.warn('Local storage is still full, skipping cache.', { payload, max, inUse, regex, area: area.name });
131178
return Promise.resolve();
132179
}
133180

134-
return storage.local.set(key, value);
181+
try {
182+
return area.set(key, value);
183+
} catch (error) {
184+
if (isQuotaExceededError(error)) {
185+
console.warn('Local storage is full, clearing cache.', { payload, max, inUse, regex, area: area.name });
186+
if (regex) await area.removeAll(regex);
187+
else await area.clear();
188+
return area.set(key, value);
189+
}
190+
throw error;
191+
}
135192
};

src/utils/cache.utils.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CacheRetention } from '@dvcol/common-utils/common/cache';
33
import type { ResponseOrTypedResponse, TypedResponse } from '@dvcol/base-http-client';
44
import type { CacheStore, CacheStoreEntity } from '@dvcol/common-utils/common';
55

6-
import { localCache, storage, type StorageArea } from '~/utils/browser/browser-storage.utils';
6+
import { setStorageWrapper, storage, type StorageArea } from '~/utils/browser/browser-storage.utils';
77

88
type FlatResponse<T extends Response = ResponseOrTypedResponse> = Record<keyof T, unknown>;
99

@@ -61,7 +61,7 @@ export class ChromeCacheStore<T> implements CacheStore<T> {
6161
this.retention = retention;
6262
this.store = {
6363
...store,
64-
set: <V>(key: string, value: V) => localCache(key, value, this.prefix),
64+
set: <V>(key: string, value: V) => setStorageWrapper(key, value, this.prefix, store),
6565
};
6666
this.prefix = prefix;
6767
}

0 commit comments

Comments
 (0)