react-inlinesvg
Version:
An SVG loader for React
206 lines (159 loc) • 5.19 kB
text/typescript
import { CACHE_MAX_RETRIES, CACHE_NAME, STATUS } from '../config';
import { StorageItem } from '../types';
import { canUseDOM, request } from './helpers';
export interface CacheStoreOptions {
name?: string;
persistent?: boolean;
}
export default class CacheStore {
private cacheApi: Cache | undefined;
private readonly cacheStore: Map<string, StorageItem>;
private readonly subscribers: Array<() => void> = [];
public isReady = false;
constructor(options: CacheStoreOptions = {}) {
const { name = CACHE_NAME, persistent = false } = options;
this.cacheStore = new Map<string, StorageItem>();
const usePersistentCache = persistent && canUseDOM() && 'caches' in window;
if (usePersistentCache) {
// eslint-disable-next-line promise/catch-or-return
caches
.open(name)
.then(cache => {
this.cacheApi = cache;
})
.catch(error => {
// eslint-disable-next-line no-console
console.error(`Failed to open cache: ${error.message}`);
this.cacheApi = undefined;
})
.finally(() => {
this.isReady = true;
// Copy to avoid mutation issues
const callbacks = [...this.subscribers];
// Clear array efficiently
this.subscribers.length = 0;
callbacks.forEach(callback => {
try {
callback();
} catch (error: any) {
// eslint-disable-next-line no-console
console.error(`Error in CacheStore subscriber callback: ${error.message}`);
}
});
});
} else {
this.isReady = true;
}
}
public onReady(callback: () => void): () => void {
if (this.isReady) {
callback();
return () => {};
}
this.subscribers.push(callback);
return () => {
const index = this.subscribers.indexOf(callback);
if (index >= 0) {
this.subscribers.splice(index, 1);
}
};
}
private waitForReady(): Promise<void> {
if (this.isReady) {
return Promise.resolve();
}
return new Promise(resolve => {
this.onReady(resolve);
});
}
public async get(url: string, fetchOptions?: RequestInit) {
await this.fetchAndCache(url, fetchOptions);
return this.cacheStore.get(url)?.content ?? '';
}
public getContent(url: string): string {
return this.cacheStore.get(url)?.content ?? '';
}
public set(url: string, data: StorageItem) {
this.cacheStore.set(url, data);
}
public isCached(url: string) {
return this.cacheStore.get(url)?.status === STATUS.LOADED;
}
private async fetchAndCache(url: string, fetchOptions?: RequestInit) {
if (!this.isReady) {
await this.waitForReady();
}
const cache = this.cacheStore.get(url);
if (cache?.status === STATUS.LOADED) {
return;
}
if (cache?.status === STATUS.LOADING) {
await this.handleLoading(url, fetchOptions?.signal || undefined, async () => {
this.cacheStore.set(url, { content: '', status: STATUS.IDLE });
await this.fetchAndCache(url, fetchOptions);
});
return;
}
this.cacheStore.set(url, { content: '', status: STATUS.LOADING });
try {
const content = this.cacheApi
? await this.fetchFromPersistentCache(url, fetchOptions)
: await request(url, fetchOptions);
this.cacheStore.set(url, { content, status: STATUS.LOADED });
} catch (error: any) {
this.cacheStore.set(url, { content: '', status: STATUS.FAILED });
throw error;
}
}
private async fetchFromPersistentCache(url: string, fetchOptions?: RequestInit): Promise<string> {
const data = await this.cacheApi?.match(url);
if (data) {
return data.text();
}
await this.cacheApi?.add(new Request(url, fetchOptions));
const response = await this.cacheApi?.match(url);
return (await response?.text()) ?? '';
}
private async handleLoading(
url: string,
signal: AbortSignal | undefined,
callback: () => Promise<void>,
) {
for (let retryCount = 0; retryCount < CACHE_MAX_RETRIES; retryCount++) {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted.', 'AbortError');
}
if (this.cacheStore.get(url)?.status !== STATUS.LOADING) {
return;
}
await sleep(0.1);
}
await callback();
}
public keys(): Array<string> {
return [...this.cacheStore.keys()];
}
public data(): Array<Record<string, StorageItem>> {
return [...this.cacheStore.entries()].map(([key, value]) => ({ [key]: value }));
}
public async delete(url: string) {
if (this.cacheApi) {
await this.cacheApi.delete(url);
}
this.cacheStore.delete(url);
}
public async clear() {
if (this.cacheApi) {
const keys = await this.cacheApi.keys();
await Promise.allSettled(keys.map(key => this.cacheApi!.delete(key)));
}
this.cacheStore.clear();
}
}
function sleep(seconds = 1) {
return new Promise(resolve => {
setTimeout(resolve, seconds * 1000);
});
}