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:
schroda
2025-11-22 16:56:53 +01:00
parent 704d3b191f
commit 9ed05f3fc0
11 changed files with 1101 additions and 82 deletions

1
.gitignore vendored
View File

@@ -5,5 +5,6 @@ node_modules/
.idea .idea
build/* build/*
dev-dist/*
src/lib/graphql/schema.json src/lib/graphql/schema.json

View File

@@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Browse**) Add "open in webview" button in source browse page - (**Browse**) Add "open in webview" button in source browse page
### Changed ### Changed
- (**General**) Improve loading of images
- (**Category**) Prevent creating categories without a name - (**Category**) Prevent creating categories without a name
- (**WebUI Update**) Do not require a forced page refresh when an update has been detected in case the app just got opened - (**WebUI Update**) Do not require a forced page refresh when an update has been detected in case the app just got opened

View File

@@ -126,7 +126,9 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "7.2.2", "vite": "7.2.2",
"vite-plugin-node-polyfills": "0.24.0", "vite-plugin-node-polyfills": "0.24.0",
"vite-plugin-pwa": "1.1.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"workbox-window": "7.4.0",
"yargs": "18.0.0" "yargs": "18.0.0"
} }
} }

View File

@@ -1269,9 +1269,9 @@
"clear_cache": { "clear_cache": {
"label": { "label": {
"description": "The cache of the client (browser, electron) should get cleared alongside it, otherwise, the client cache will keep getting used", "description": "The cache of the client (browser, electron) should get cleared alongside it, otherwise, the client cache will keep getting used",
"failure": "Could not clear the server cache", "failure": "Could not clear the cache",
"success": "Cleared the server cache", "success": "Cleared the cache",
"title": "Clear server cache" "title": "Clear cache"
} }
}, },
"default_value": "{{value}}<0>($t(global.label.default))</0>", "default_value": "{{value}}<0>($t(global.label.default))</0>",

View File

@@ -16,10 +16,11 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ImageIcon from '@mui/icons-material/Image'; import ImageIcon from '@mui/icons-material/Image';
import { SxProps, Theme } from '@mui/material/styles'; 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 { Priority } from '@/lib/Queue.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts'; import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx'; import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
import { noOp } from '@/lib/HelperFunctions.ts';
export interface SpinnerImageProps { export interface SpinnerImageProps {
shouldLoad?: boolean; shouldLoad?: boolean;
@@ -97,38 +98,32 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
return () => {}; return () => {};
} }
const imageRequest = requestManager.requestImage(src, { let imageRequest: ImageRequest = {
response: Promise.resolve(''),
cleanup: noOp,
abortRequest: noOp,
fromCache: false,
};
const fetchImage = async () => {
try {
imageRequest = await requestManager.requestImage(src, {
priority, priority,
shouldDecode, shouldDecode,
useFetchApi, useFetchApi,
disableCors, disableCors,
}); });
let cacheTimeout: NodeJS.Timeout;
const fetchImage = async () => { if (!imageRequest.fromCache) {
try { updateImageState(true);
const updateImage = async () => {
const image = await imageRequest.response;
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;
} }
updateImageState(true); const image = await imageRequest.response;
await updateImage();
if (!imageRequest.fromCache) {
updateImageState(false);
}
setImageSourceUrl(image);
} catch (e) { } catch (e) {
const wasAborted = const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted'); e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
@@ -140,7 +135,6 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
return () => { return () => {
imageRequest.cleanup(); imageRequest.cleanup();
clearTimeout(cacheTimeout);
imageRequest.abortRequest(new Error('Component was unmounted')); imageRequest.abortRequest(new Error('Component was unmounted'));
}; };
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]); }, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
@@ -181,7 +175,7 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
/> />
)} )}
{(isLoading || (src && !imageSourceUrl) || hasError) && ( {(!!isLoading || (src && !imageSourceUrl) || hasError) && (
<Stack <Stack
ref={loadingIndicatorRef} ref={loadingIndicatorRef}
sx={{ sx={{
@@ -198,9 +192,7 @@ export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
{isVisible && (isLoading || (src && !imageSourceUrl && !hasError)) && ( {isVisible && !!isLoading && <CircularProgress thickness={5} />}
<CircularProgress thickness={5} />
)}
{hasError && isLoading === false && ( {hasError && isLoading === false && (
<> <>
<BrokenImageIcon /> <BrokenImageIcon />

View File

@@ -29,6 +29,7 @@ import { makeToast } from '@/base/utils/Toast.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts'; import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { ImageCache } from '@/lib/service-worker/ImageCache.ts';
export function Settings() { export function Settings() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,9 +38,9 @@ export function Settings() {
const [triggerClearServerCache, { loading: isClearingServerCache }] = requestManager.useClearServerCache(); const [triggerClearServerCache, { loading: isClearingServerCache }] = requestManager.useClearServerCache();
const clearServerCache = async () => { const clearCache = async () => {
try { try {
await triggerClearServerCache(); await Promise.all([triggerClearServerCache(), ImageCache.clear()]);
makeToast(t('settings.clear_cache.label.success'), 'success'); makeToast(t('settings.clear_cache.label.success'), 'success');
} catch (e) { } catch (e) {
makeToast(t('settings.clear_cache.label.failure'), 'error', getErrorMessage(e)); makeToast(t('settings.clear_cache.label.failure'), 'error', getErrorMessage(e));
@@ -85,7 +86,7 @@ export function Settings() {
<ListItemText primary={t('settings.backup.title')} /> <ListItemText primary={t('settings.backup.title')} />
</ListItemLink> </ListItemLink>
<ListItemButton disabled={isClearingServerCache} onClick={clearServerCache}> <ListItemButton disabled={isClearingServerCache} onClick={clearCache}>
<ListItemIcon> <ListItemIcon>
<DeleteForeverIcon /> <DeleteForeverIcon />
</ListItemIcon> </ListItemIcon>

View File

@@ -14,20 +14,8 @@ import '@/index.css';
import '@/lib/PointerDeviceUtil.ts'; import '@/lib/PointerDeviceUtil.ts';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { App } from '@/App'; 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 container = document.getElementById('root');
const root = createRoot(container!); const root = createRoot(container!);
root.render( root.render(

View File

@@ -340,6 +340,7 @@ import { updateMetadataList } from '@/features/metadata/services/MetadataApolloC
import { USER_LOGIN, USER_REFRESH } from '@/lib/graphql/mutations/UserMutation.ts'; import { USER_LOGIN, USER_REFRESH } from '@/lib/graphql/mutations/UserMutation.ts';
import { AuthManager } from '@/features/authentication/AuthManager.ts'; import { AuthManager } from '@/features/authentication/AuthManager.ts';
import { useLocalStorage } from '@/base/hooks/useStorage.tsx'; import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
import { ImageCache } from '@/lib/service-worker/ImageCache.ts';
enum GQLMethod { enum GQLMethod {
QUERY = 'QUERY', QUERY = 'QUERY',
@@ -390,7 +391,7 @@ type SubscriptionHookOptions<Data = any, Variables extends OperationVariables =
type AbortableRequest = { abortRequest: AbortController['abort'] }; 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> = { export type AbortabaleApolloQueryResponse<Data = any> = {
response: Promise<ApolloQueryResult<MaybeMasked<Data>>>; response: Promise<ApolloQueryResult<MaybeMasked<Data>>>;
@@ -929,14 +930,34 @@ export class RequestManager {
return url; 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, url: string,
{ {
priority, priority,
shouldDecode, shouldDecode,
disableCors, disableCors,
}: { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean } = {}, }: { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean } = {},
): ImageRequest { ): Promise<ImageRequest> {
const imgRequest = new ControlledPromise<string>(); const imgRequest = new ControlledPromise<string>();
imgRequest.promise.catch(() => {}); imgRequest.promise.catch(() => {});
@@ -949,7 +970,11 @@ export class RequestManager {
imgRequest.reject(reason); imgRequest.reject(reason);
}; };
const { key, promise: response } = this.imageQueue.enqueue( const {
key,
promise: response,
fromCache,
} = await this.maybeEnqueueImageRequest(
url, url,
async () => { async () => {
// throws error in case request was already aborted // throws error in case request was already aborted
@@ -981,6 +1006,7 @@ export class RequestManager {
response, response,
abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)), abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)),
cleanup: () => {}, cleanup: () => {},
fromCache: !!fromCache,
}; };
} }
@@ -997,17 +1023,22 @@ export class RequestManager {
* img.src = imageUrl; * img.src = imageUrl;
* *
*/ */
private fetchImageViaFetchApi( private async fetchImageViaFetchApi(
url: string, url: string,
{ {
priority, priority,
shouldDecode, shouldDecode,
disableCors, disableCors,
}: { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean } = {}, }: { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean } = {},
): ImageRequest { ): Promise<ImageRequest> {
let objectUrl: string = ''; let objectUrl: string = '';
const { abortRequest, signal } = this.createAbortController(); const { abortRequest, signal } = this.createAbortController();
const { key, promise: response } = this.imageQueue.enqueue(
const {
key,
promise: response,
fromCache,
} = await this.maybeEnqueueImageRequest(
url, url,
() => () =>
this.restClient this.restClient
@@ -1034,6 +1065,7 @@ export class RequestManager {
response, response,
abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)), abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)),
cleanup: () => URL.revokeObjectURL(objectUrl), cleanup: () => URL.revokeObjectURL(objectUrl),
fromCache: !!fromCache,
}; };
} }
@@ -1043,7 +1075,7 @@ export class RequestManager {
* options: * 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 * - 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, url: string,
options: { options: {
priority?: QueuePriority; priority?: QueuePriority;
@@ -1051,7 +1083,7 @@ export class RequestManager {
shouldDecode?: boolean; shouldDecode?: boolean;
disableCors?: boolean; disableCors?: boolean;
} = {}, } = {},
): ImageRequest { ): Promise<ImageRequest> {
const finalOptions = { const finalOptions = {
useFetchApi: AuthManager.isAuthRequired(), useFetchApi: AuthManager.isAuthRequired(),
shouldDecode: false, shouldDecode: false,

View 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)));
}
}

View File

@@ -14,6 +14,7 @@ import react from '@vitejs/plugin-react-swc';
import viteTsconfigPaths from 'vite-tsconfig-paths'; import viteTsconfigPaths from 'vite-tsconfig-paths';
import legacy from '@vitejs/plugin-legacy'; import legacy from '@vitejs/plugin-legacy';
import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { VitePWA } from 'vite-plugin-pwa';
import 'dotenv/config'; import 'dotenv/config';
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
@@ -49,5 +50,43 @@ export default defineConfig(({ command }) => ({
nodePolyfills({ nodePolyfills({
include: ['assert'], include: ['assert'],
}), }),
// Only setup image runtime caching
VitePWA({
registerType: 'autoUpdate',
manifest: false, // Use existing manifest
devOptions: {
enabled: true,
},
workbox: {
globPatterns: [],
runtimeCaching: [
{
urlPattern: ({ request, url }) => {
if (request.destination === 'image') {
return true;
}
const { pathname } = url;
return (
pathname.match(/\/chapter\/[0-9]+\/page\/[0-9]+/g) ||
pathname.match(/\/manga\/[0-9]+\/thumbnail/g) ||
pathname.includes('/extension/icon/')
);
},
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 10000,
purgeOnQuotaError: true,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
}),
], ],
})); }));

967
yarn.lock

File diff suppressed because it is too large Load Diff