Cache images via service worker
Improves the image request logic by making it possible to check if an image is cached. In case an image is cached, it does not need to be put into the image queue because it won't be sent to the server.
This commit is contained in:
@@ -16,10 +16,11 @@ import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { ImageRequest, requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { Priority } from '@/lib/Queue.ts';
|
||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
|
||||
import { noOp } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
export interface SpinnerImageProps {
|
||||
shouldLoad?: boolean;
|
||||
@@ -97,38 +98,32 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const imageRequest = requestManager.requestImage(src, {
|
||||
priority,
|
||||
shouldDecode,
|
||||
useFetchApi,
|
||||
disableCors,
|
||||
});
|
||||
let cacheTimeout: NodeJS.Timeout;
|
||||
|
||||
let imageRequest: ImageRequest = {
|
||||
response: Promise.resolve(''),
|
||||
cleanup: noOp,
|
||||
abortRequest: noOp,
|
||||
fromCache: false,
|
||||
};
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
const updateImage = async () => {
|
||||
const image = await imageRequest.response;
|
||||
imageRequest = await requestManager.requestImage(src, {
|
||||
priority,
|
||||
shouldDecode,
|
||||
useFetchApi,
|
||||
disableCors,
|
||||
});
|
||||
|
||||
updateImageState(false);
|
||||
setImageSourceUrl(image);
|
||||
};
|
||||
|
||||
const checkCache = await Promise.race([
|
||||
imageRequest.response,
|
||||
new Promise((resolve) => {
|
||||
cacheTimeout = setTimeout(resolve, 50);
|
||||
}),
|
||||
]);
|
||||
const isImageCached = !!checkCache;
|
||||
|
||||
if (isImageCached) {
|
||||
await updateImage();
|
||||
return;
|
||||
if (!imageRequest.fromCache) {
|
||||
updateImageState(true);
|
||||
}
|
||||
|
||||
updateImageState(true);
|
||||
await updateImage();
|
||||
const image = await imageRequest.response;
|
||||
|
||||
if (!imageRequest.fromCache) {
|
||||
updateImageState(false);
|
||||
}
|
||||
|
||||
setImageSourceUrl(image);
|
||||
} catch (e) {
|
||||
const wasAborted =
|
||||
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
|
||||
@@ -140,7 +135,6 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
|
||||
|
||||
return () => {
|
||||
imageRequest.cleanup();
|
||||
clearTimeout(cacheTimeout);
|
||||
imageRequest.abortRequest(new Error('Component was unmounted'));
|
||||
};
|
||||
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
|
||||
@@ -181,7 +175,7 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLoading || (src && !imageSourceUrl) || hasError) && (
|
||||
{(!!isLoading || (src && !imageSourceUrl) || hasError) && (
|
||||
<Stack
|
||||
ref={loadingIndicatorRef}
|
||||
sx={{
|
||||
@@ -198,9 +192,7 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{isVisible && (isLoading || (src && !imageSourceUrl && !hasError)) && (
|
||||
<CircularProgress thickness={5} />
|
||||
)}
|
||||
{isVisible && !!isLoading && <CircularProgress thickness={5} />}
|
||||
{hasError && isLoading === false && (
|
||||
<>
|
||||
<BrokenImageIcon />
|
||||
|
||||
@@ -29,6 +29,7 @@ import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
|
||||
import { ImageCache } from '@/lib/service-worker/ImageCache.ts';
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,9 +38,9 @@ export function Settings() {
|
||||
|
||||
const [triggerClearServerCache, { loading: isClearingServerCache }] = requestManager.useClearServerCache();
|
||||
|
||||
const clearServerCache = async () => {
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
await triggerClearServerCache();
|
||||
await Promise.all([triggerClearServerCache(), ImageCache.clear()]);
|
||||
makeToast(t('settings.clear_cache.label.success'), 'success');
|
||||
} catch (e) {
|
||||
makeToast(t('settings.clear_cache.label.failure'), 'error', getErrorMessage(e));
|
||||
@@ -85,7 +86,7 @@ export function Settings() {
|
||||
<ListItemText primary={t('settings.backup.title')} />
|
||||
</ListItemLink>
|
||||
|
||||
<ListItemButton disabled={isClearingServerCache} onClick={clearServerCache}>
|
||||
<ListItemButton disabled={isClearingServerCache} onClick={clearCache}>
|
||||
<ListItemIcon>
|
||||
<DeleteForeverIcon />
|
||||
</ListItemIcon>
|
||||
|
||||
@@ -14,20 +14,8 @@ import '@/index.css';
|
||||
import '@/lib/PointerDeviceUtil.ts';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { App } from '@/App';
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().catch(defaultPromiseErrorHandler('unregister service workers'));
|
||||
if (caches) {
|
||||
caches.keys().then(async (names) => {
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container!);
|
||||
root.render(
|
||||
|
||||
@@ -340,6 +340,7 @@ import { updateMetadataList } from '@/features/metadata/services/MetadataApolloC
|
||||
import { USER_LOGIN, USER_REFRESH } from '@/lib/graphql/mutations/UserMutation.ts';
|
||||
import { AuthManager } from '@/features/authentication/AuthManager.ts';
|
||||
import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
|
||||
import { ImageCache } from '@/lib/service-worker/ImageCache.ts';
|
||||
|
||||
enum GQLMethod {
|
||||
QUERY = 'QUERY',
|
||||
@@ -390,7 +391,7 @@ type SubscriptionHookOptions<Data = any, Variables extends OperationVariables =
|
||||
|
||||
type AbortableRequest = { abortRequest: AbortController['abort'] };
|
||||
|
||||
type ImageRequest = { response: Promise<string>; cleanup: () => void } & AbortableRequest;
|
||||
export type ImageRequest = { response: Promise<string>; cleanup: () => void; fromCache: boolean } & AbortableRequest;
|
||||
|
||||
export type AbortabaleApolloQueryResponse<Data = any> = {
|
||||
response: Promise<ApolloQueryResult<MaybeMasked<Data>>>;
|
||||
@@ -929,14 +930,34 @@ export class RequestManager {
|
||||
return url;
|
||||
}
|
||||
|
||||
private fetchImageViaTag(
|
||||
private async maybeEnqueueImageRequest<T>(
|
||||
url: string,
|
||||
request: () => Promise<T>,
|
||||
priority?: QueuePriority,
|
||||
): Promise<ReturnType<typeof this.imageQueue.enqueue<T>> & { fromCache?: boolean }> {
|
||||
try {
|
||||
if (await ImageCache.has(url)) {
|
||||
return {
|
||||
key: `image-cache-${url}`,
|
||||
promise: request(),
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
|
||||
return this.imageQueue.enqueue(url, request, priority);
|
||||
} catch (error) {
|
||||
return this.imageQueue.enqueue(url, request, priority);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchImageViaTag(
|
||||
url: string,
|
||||
{
|
||||
priority,
|
||||
shouldDecode,
|
||||
disableCors,
|
||||
}: { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean } = {},
|
||||
): ImageRequest {
|
||||
): Promise<ImageRequest> {
|
||||
const imgRequest = new ControlledPromise<string>();
|
||||
imgRequest.promise.catch(() => {});
|
||||
|
||||
@@ -949,7 +970,11 @@ export class RequestManager {
|
||||
imgRequest.reject(reason);
|
||||
};
|
||||
|
||||
const { key, promise: response } = this.imageQueue.enqueue(
|
||||
const {
|
||||
key,
|
||||
promise: response,
|
||||
fromCache,
|
||||
} = await this.maybeEnqueueImageRequest(
|
||||
url,
|
||||
async () => {
|
||||
// throws error in case request was already aborted
|
||||
@@ -981,6 +1006,7 @@ export class RequestManager {
|
||||
response,
|
||||
abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)),
|
||||
cleanup: () => {},
|
||||
fromCache: !!fromCache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -997,17 +1023,22 @@ export class RequestManager {
|
||||
* img.src = imageUrl;
|
||||
*
|
||||
*/
|
||||
private fetchImageViaFetchApi(
|
||||
private async fetchImageViaFetchApi(
|
||||
url: string,
|
||||
{
|
||||
priority,
|
||||
shouldDecode,
|
||||
disableCors,
|
||||
}: { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean } = {},
|
||||
): ImageRequest {
|
||||
): Promise<ImageRequest> {
|
||||
let objectUrl: string = '';
|
||||
const { abortRequest, signal } = this.createAbortController();
|
||||
const { key, promise: response } = this.imageQueue.enqueue(
|
||||
|
||||
const {
|
||||
key,
|
||||
promise: response,
|
||||
fromCache,
|
||||
} = await this.maybeEnqueueImageRequest(
|
||||
url,
|
||||
() =>
|
||||
this.restClient
|
||||
@@ -1034,6 +1065,7 @@ export class RequestManager {
|
||||
response,
|
||||
abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)),
|
||||
cleanup: () => URL.revokeObjectURL(objectUrl),
|
||||
fromCache: !!fromCache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1043,7 +1075,7 @@ export class RequestManager {
|
||||
* options:
|
||||
* - shouldDecode: decodes the image in case the browser is Firefox to prevent a flickering/blinking when an image gets visible for the first time
|
||||
*/
|
||||
public requestImage(
|
||||
public async requestImage(
|
||||
url: string,
|
||||
options: {
|
||||
priority?: QueuePriority;
|
||||
@@ -1051,7 +1083,7 @@ export class RequestManager {
|
||||
shouldDecode?: boolean;
|
||||
disableCors?: boolean;
|
||||
} = {},
|
||||
): ImageRequest {
|
||||
): Promise<ImageRequest> {
|
||||
const finalOptions = {
|
||||
useFetchApi: AuthManager.isAuthRequired(),
|
||||
shouldDecode: false,
|
||||
|
||||
40
src/lib/service-worker/ImageCache.ts
Normal file
40
src/lib/service-worker/ImageCache.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'image-cache';
|
||||
|
||||
export class ImageCache {
|
||||
static async has(url: string): Promise<boolean> {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const response = await cache.match(url, { ignoreVary: true });
|
||||
|
||||
return response !== undefined;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async get(url: string): Promise<Response | null> {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const response = await cache.match(url);
|
||||
|
||||
return response || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async clear(): Promise<void> {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const keys = await cache.keys();
|
||||
|
||||
await Promise.all(keys.map((key) => cache.delete(key)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user