Compare commits

..

6 Commits

Author SHA1 Message Date
Julien Froidefond
034aa69f8d feat: update service worker to version 2.5 and enhance caching strategies for network requests, including cache bypass for refresh actions in LibraryClientWrapper, SeriesClientWrapper, and HomeClientWrapper components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m3s
2026-01-04 11:44:50 +01:00
Julien Froidefond
060dfb3099 fix: adjust thumbnail size and optimize image loading in BookDownloadCard component
Some checks are pending
Deploy with Docker Compose / deploy (push) Has started running
2026-01-04 11:41:13 +01:00
Julien Froidefond
ad11bce308 revert: restore page-by-page download method (old method works better) 2026-01-04 11:39:55 +01:00
Julien Froidefond
1ffe99285d feat: add fflate library for file decompression and implement file download functionality in BookOfflineButton component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m18s
2026-01-04 11:32:48 +01:00
Julien Froidefond
0d33462349 feat: update service worker to version 2.4, enhance caching strategies for pages, and add service worker reinstallation functionality in CacheSettings component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2026-01-04 07:39:07 +01:00
Julien Froidefond
b8a0b85c54 refactor: rename Image import to ImageIcon for clarity in CacheSettings component and remove unused React import in collapsible component 2026-01-04 07:18:22 +01:00
10 changed files with 378 additions and 146 deletions

View File

@@ -1,11 +1,11 @@
// StripStream Service Worker - Version 2
// Architecture: SWR (Stale-While-Revalidate) for all resources
const VERSION = "v2";
const VERSION = "v2.5";
const STATIC_CACHE = `stripstream-static-${VERSION}`;
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
const API_CACHE = `stripstream-api-${VERSION}`;
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
const OFFLINE_PAGE = "/offline.html";
@@ -129,10 +129,23 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
/**
* Stale-While-Revalidate: Serve from cache immediately, update in background
* Used for: API calls, images
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
*/
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Check if client requested no-cache (refresh button, router.refresh(), etc.)
// 1. Check Cache-Control header
const cacheControl = request.headers.get("Cache-Control");
const noCacheHeader =
cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store"));
// 2. Check request.cache mode (used by Next.js router.refresh())
const noCacheMode =
request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload";
const noCache = noCacheHeader || noCacheMode;
// If no-cache, skip cached response and go network-first
const cached = noCache ? null : await cache.match(request);
// Start network request (don't await)
const fetchPromise = fetch(request)
@@ -195,39 +208,50 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
}
/**
* Network-First: Try network, fallback to cache
* Navigation SWR: Serve from cache immediately, update in background
* Falls back to offline page if nothing cached
* Used for: Page navigations
*/
async function networkFirstStrategy(request, cacheName) {
async function navigationSWRStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed - try cache
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Start network request in background
const fetchPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
await cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
// Try to serve root page for SPA client-side routing
const rootPage = await cache.match("/");
if (rootPage) {
return rootPage;
}
// Last resort: offline page
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw error;
// Return cached version immediately if available
if (cached) {
return cached;
}
// No cache - wait for network
const response = await fetchPromise;
if (response) {
return response;
}
// Network failed and no cache - try fallbacks
// Try to serve root page for SPA client-side routing
const rootPage = await cache.match("/");
if (rootPage) {
return rootPage;
}
// Last resort: offline page (in static cache)
const staticCache = await caches.open(STATIC_CACHE);
const offlinePage = await staticCache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw new Error("Offline and no cached page available");
}
// ============================================================================
@@ -262,7 +286,7 @@ self.addEventListener("activate", (event) => {
(async () => {
// Clean up old caches, but preserve BOOKS_CACHE
const cacheNames = await caches.keys();
const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE];
const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
const cachesToDelete = cacheNames.filter(
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
@@ -295,20 +319,23 @@ self.addEventListener("message", async (event) => {
switch (type) {
case "GET_CACHE_STATS": {
try {
const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([
const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
getCacheSize(STATIC_CACHE),
getCacheSize(PAGES_CACHE),
getCacheSize(API_CACHE),
getCacheSize(IMAGES_CACHE),
getCacheSize(BOOKS_CACHE),
]);
const staticCache = await caches.open(STATIC_CACHE);
const pagesCache = await caches.open(PAGES_CACHE);
const apiCache = await caches.open(API_CACHE);
const imagesCache = await caches.open(IMAGES_CACHE);
const booksCache = await caches.open(BOOKS_CACHE);
const [staticKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
staticCache.keys(),
pagesCache.keys(),
apiCache.keys(),
imagesCache.keys(),
booksCache.keys(),
@@ -318,10 +345,11 @@ self.addEventListener("message", async (event) => {
type: "CACHE_STATS",
payload: {
static: { size: staticSize, entries: staticKeys.length },
pages: { size: pagesSize, entries: pagesKeys.length },
api: { size: apiSize, entries: apiKeys.length },
images: { size: imagesSize, entries: imagesKeys.length },
books: { size: booksSize, entries: booksKeys.length },
total: staticSize + apiSize + imagesSize + booksSize,
total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
},
});
} catch (error) {
@@ -341,15 +369,15 @@ self.addEventListener("message", async (event) => {
if (cacheType === "all" || cacheType === "static") {
cachesToClear.push(STATIC_CACHE);
}
if (cacheType === "all" || cacheType === "pages") {
cachesToClear.push(PAGES_CACHE);
}
if (cacheType === "all" || cacheType === "api") {
cachesToClear.push(API_CACHE);
}
if (cacheType === "all" || cacheType === "images") {
cachesToClear.push(IMAGES_CACHE);
}
if (cacheType === "all" || cacheType === "rsc") {
cachesToClear.push(RSC_CACHE);
}
// Note: BOOKS_CACHE is not cleared by default, only explicitly
await Promise.all(
@@ -395,6 +423,9 @@ self.addEventListener("message", async (event) => {
case "static":
cacheName = STATIC_CACHE;
break;
case "pages":
cacheName = PAGES_CACHE;
break;
case "api":
cacheName = API_CACHE;
break;
@@ -477,10 +508,10 @@ self.addEventListener("fetch", (event) => {
return;
}
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
// Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
if (isNextRSCRequest(request)) {
event.respondWith(
staleWhileRevalidateStrategy(request, RSC_CACHE, {
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
notifyOnChange: false,
})
);
@@ -515,9 +546,9 @@ self.addEventListener("fetch", (event) => {
return;
}
// Route 6: Navigation → Network-First with SPA fallback
// Route 6: Navigation → SWR (cache first, revalidate in background)
if (request.mode === "navigate") {
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
return;
}

View File

@@ -17,21 +17,45 @@ interface LibraryClientWrapperProps {
preferences: UserPreferences;
}
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
export function LibraryClientWrapper({
children,
libraryId,
currentPage,
unreadOnly,
search,
pageSize,
}: LibraryClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// Revalider la page côté serveur
// Fetch fresh data from network with cache bypass
const params = new URLSearchParams({
page: String(currentPage),
size: String(pageSize),
...(unreadOnly && { unreadOnly: "true" }),
...(search && { search }),
});
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh library");
}
// Trigger Next.js revalidation to update the UI
router.refresh();
return { success: true };
} catch {
return { success: false, error: "Error refreshing library" };
} finally {
// Petit délai pour laisser le temps au serveur de revalider
setTimeout(() => setIsRefreshing(false), 500);
setIsRefreshing(false);
}
};

View File

@@ -18,6 +18,10 @@ interface SeriesClientWrapperProps {
export function SeriesClientWrapper({
children,
seriesId,
currentPage,
unreadOnly,
pageSize,
}: SeriesClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -25,14 +29,30 @@ export function SeriesClientWrapper({
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// Revalider la page côté serveur
// Fetch fresh data from network with cache bypass
const params = new URLSearchParams({
page: String(currentPage),
size: String(pageSize),
...(unreadOnly && { unreadOnly: "true" }),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh series");
}
// Trigger Next.js revalidation to update the UI
router.refresh();
return { success: true };
} catch {
return { success: false, error: "Error refreshing series" };
} finally {
// Petit délai pour laisser le temps au serveur de revalider
setTimeout(() => setIsRefreshing(false), 500);
setIsRefreshing(false);
}
};
@@ -52,10 +72,7 @@ export function SeriesClientWrapper({
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<RefreshProvider refreshSeries={handleRefresh}>
{children}
</RefreshProvider>
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
</>
);
}

View File

@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
return (
<Card className="p-4">
<div className="flex items-center gap-4">
<div className="relative w-12 aspect-[2/3] bg-muted/80 backdrop-blur-md rounded overflow-hidden">
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
<Image
src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={t("books.coverAlt", { title: book.metadata?.title })}
className="object-cover"
fill
sizes="48px"
sizes="64px"
priority={false}
unoptimized
/>
</div>
<div className="flex-1 min-w-0">

View File

@@ -20,15 +20,25 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// Revalider la page côté serveur
// Fetch fresh data from network with cache bypass
const response = await fetch("/api/komga/home", {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh home");
}
// Trigger Next.js revalidation to update the UI
router.refresh();
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
} finally {
// Petit délai pour laisser le temps au serveur de revalider
setTimeout(() => setIsRefreshing(false), 500);
setIsRefreshing(false);
}
};

View File

@@ -8,18 +8,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Database,
Trash2,
RefreshCw,
HardDrive,
Image,
Image as ImageIcon,
FileJson,
BookOpen,
CheckCircle2,
@@ -27,10 +23,13 @@ import {
Loader2,
ChevronDown,
ChevronRight,
LayoutGrid,
RotateCcw,
} from "lucide-react";
interface CacheStats {
static: { size: number; entries: number };
pages: { size: number; entries: number };
api: { size: number; entries: number };
images: { size: number; entries: number };
books: { size: number; entries: number };
@@ -42,7 +41,7 @@ interface CacheEntry {
size: number;
}
type CacheType = "static" | "api" | "images" | "books";
type CacheType = "static" | "pages" | "api" | "images" | "books";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
@@ -195,12 +194,20 @@ function CacheItem({
export function CacheSettings() {
const { t } = useTranslate();
const { toast } = useToast();
const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } =
useServiceWorker();
const {
isSupported,
isReady,
version,
getCacheStats,
getCacheEntries,
clearCache,
reinstallServiceWorker,
} = useServiceWorker();
const [stats, setStats] = useState<CacheStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [clearingCache, setClearingCache] = useState<string | null>(null);
const [isReinstalling, setIsReinstalling] = useState(false);
const loadStats = useCallback(async () => {
if (!isReady) return;
@@ -218,7 +225,7 @@ export function CacheSettings() {
loadStats();
}, [loadStats]);
const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => {
const handleClearCache = async (cacheType: "all" | "static" | "pages" | "api" | "images") => {
setClearingCache(cacheType);
try {
const success = await clearCache(cacheType);
@@ -247,6 +254,28 @@ export function CacheSettings() {
[getCacheEntries]
);
const handleReinstall = async () => {
setIsReinstalling(true);
try {
const success = await reinstallServiceWorker();
if (!success) {
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.cache.reinstallError"),
});
}
// If success, the page will reload automatically
} catch {
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.cache.reinstallError"),
});
setIsReinstalling(false);
}
};
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
const maxCacheSize = 100 * 1024 * 1024; // 100MB
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
@@ -328,6 +357,17 @@ export function CacheSettings() {
isClearing={clearingCache === "static"}
onLoadEntries={handleLoadEntries}
/>
<CacheItem
icon={<LayoutGrid className="h-4 w-4" />}
label={t("settings.cache.pages")}
size={stats.pages.size}
entries={stats.pages.entries}
cacheType="pages"
description={t("settings.cache.pagesDesc")}
onClear={() => handleClearCache("pages")}
isClearing={clearingCache === "pages"}
onLoadEntries={handleLoadEntries}
/>
<CacheItem
icon={<FileJson className="h-4 w-4" />}
label={t("settings.cache.api")}
@@ -340,7 +380,7 @@ export function CacheSettings() {
onLoadEntries={handleLoadEntries}
/>
<CacheItem
icon={<Image className="h-4 w-4" />}
icon={<ImageIcon className="h-4 w-4" />}
label={t("settings.cache.images")}
size={stats.images.size}
entries={stats.images.entries}
@@ -365,27 +405,55 @@ export function CacheSettings() {
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<p className="text-center text-muted-foreground py-8">
{t("settings.cache.unavailable")}
</p>
<div className="text-center py-8 space-y-4">
<p className="text-muted-foreground">{t("settings.cache.unavailable")}</p>
<Button
variant="outline"
onClick={handleReinstall}
disabled={isReinstalling}
className="gap-2"
>
{isReinstalling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("settings.cache.reinstall")}
</Button>
</div>
)}
</div>
{/* Bouton vider tout */}
{stats && stats.total > 0 && (
<Button
variant="destructive"
className="w-full gap-2"
onClick={() => handleClearCache("all")}
disabled={clearingCache !== null}
>
{clearingCache === "all" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.cache.clearAll")}
</Button>
<div className="space-y-2">
<Button
variant="destructive"
className="w-full gap-2"
onClick={() => handleClearCache("all")}
disabled={clearingCache !== null}
>
{clearingCache === "all" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.cache.clearAll")}
</Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={handleReinstall}
disabled={isReinstalling}
>
{isReinstalling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("settings.cache.reinstall")}
</Button>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,6 +1,5 @@
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;

View File

@@ -2,11 +2,12 @@
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { registerServiceWorker } from "@/lib/registerSW";
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
import logger from "@/lib/logger";
interface CacheStats {
static: { size: number; entries: number };
pages: { size: number; entries: number };
api: { size: number; entries: number };
images: { size: number; entries: number };
books: { size: number; entries: number };
@@ -23,7 +24,7 @@ interface CacheUpdate {
timestamp: number;
}
type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books";
type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
interface ServiceWorkerContextValue {
isSupported: boolean;
@@ -38,6 +39,7 @@ interface ServiceWorkerContextValue {
clearCache: (cacheType?: CacheType) => Promise<boolean>;
skipWaiting: () => void;
reloadForUpdate: () => void;
reinstallServiceWorker: () => Promise<boolean>;
}
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
@@ -53,76 +55,113 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
// Handle messages from service worker
const handleMessage = useCallback((event: MessageEvent) => {
const { type, payload } = event.data || {};
try {
// Ignore messages without proper data structure
if (!event.data || typeof event.data !== "object") return;
switch (type) {
case "SW_ACTIVATED":
setIsReady(true);
setVersion(payload?.version || null);
break;
// Only handle messages from our service worker (check for known message types)
const knownTypes = [
"SW_ACTIVATED",
"SW_VERSION",
"CACHE_UPDATED",
"CACHE_STATS",
"CACHE_STATS_ERROR",
"CACHE_CLEARED",
"CACHE_CLEAR_ERROR",
"CACHE_ENTRIES",
"CACHE_ENTRIES_ERROR",
];
case "SW_VERSION":
setVersion(payload?.version || null);
break;
const type = event.data.type;
if (typeof type !== "string" || !knownTypes.includes(type)) return;
case "CACHE_UPDATED":
setCacheUpdates((prev) => {
// Avoid duplicates for the same URL within 1 second
const existing = prev.find(
(u) => u.url === payload.url && Date.now() - u.timestamp < 1000
);
if (existing) return prev;
return [...prev, { url: payload.url, timestamp: payload.timestamp }];
});
break;
const payload = event.data.payload;
case "CACHE_STATS":
const statsResolver = pendingRequests.current.get("CACHE_STATS");
if (statsResolver) {
statsResolver(payload);
pendingRequests.current.delete("CACHE_STATS");
switch (type) {
case "SW_ACTIVATED":
setIsReady(true);
setVersion(payload?.version || null);
break;
case "SW_VERSION":
setVersion(payload?.version || null);
break;
case "CACHE_UPDATED": {
const url = typeof payload?.url === "string" ? payload.url : null;
const timestamp = typeof payload?.timestamp === "number" ? payload.timestamp : Date.now();
if (url) {
setCacheUpdates((prev) => {
// Avoid duplicates for the same URL within 1 second
const existing = prev.find((u) => u.url === url && Date.now() - u.timestamp < 1000);
if (existing) return prev;
return [...prev, { url, timestamp }];
});
}
break;
}
break;
case "CACHE_STATS_ERROR":
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
if (statsErrorResolver) {
statsErrorResolver(null);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_STATS":
const statsResolver = pendingRequests.current.get("CACHE_STATS");
if (statsResolver) {
statsResolver(payload);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_CLEARED":
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearResolver) {
clearResolver(true);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_STATS_ERROR":
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
if (statsErrorResolver) {
statsErrorResolver(null);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_CLEAR_ERROR":
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearErrorResolver) {
clearErrorResolver(false);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_CLEARED":
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearResolver) {
clearResolver(true);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_ENTRIES":
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesResolver) {
entriesResolver(payload.entries);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
case "CACHE_CLEAR_ERROR":
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearErrorResolver) {
clearErrorResolver(false);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_ENTRIES_ERROR":
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesErrorResolver) {
entriesErrorResolver(null);
pendingRequests.current.delete("CACHE_ENTRIES");
case "CACHE_ENTRIES": {
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesResolver) {
entriesResolver(payload?.entries || null);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
}
break;
case "CACHE_ENTRIES_ERROR": {
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesErrorResolver) {
entriesErrorResolver(null);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
}
default:
// Ignore unknown message types
break;
}
} catch (error) {
// Silently ignore message handling errors to prevent app crashes
// This can happen with malformed messages or during SW reinstall
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line no-console
console.warn("[SW Context] Error handling message:", error, event.data);
}
}
}, []);
@@ -263,6 +302,40 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
}
}, []);
const reinstallServiceWorker = useCallback(async (): Promise<boolean> => {
try {
// Unregister all service workers
await unregisterServiceWorker();
setIsReady(false);
setVersion(null);
// Re-register
const registration = await registerServiceWorker({
onSuccess: () => {
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
},
onError: (error) => {
logger.error({ err: error }, "Service worker re-registration failed");
},
});
if (registration) {
// Force update check
await registration.update();
// Reload page to ensure new SW takes control
window.location.reload();
return true;
}
return false;
} catch (error) {
logger.error({ err: error }, "Failed to reinstall service worker");
return false;
}
}, []);
return (
<ServiceWorkerContext.Provider
value={{
@@ -278,6 +351,7 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
clearCache,
skipWaiting,
reloadForUpdate,
reinstallServiceWorker,
}}
>
{children}

View File

@@ -146,6 +146,8 @@
"imagesQuota": "{used}% of images quota used",
"static": "Static resources",
"staticDesc": "Next.js scripts, styles and assets",
"pages": "Visited pages",
"pagesDesc": "Home, libraries, series and details",
"api": "API data",
"apiDesc": "Series, books and library metadata",
"images": "Images",
@@ -157,6 +159,8 @@
"clearedDesc": "Cache has been cleared successfully",
"clearError": "Error clearing cache",
"unavailable": "Cache statistics unavailable",
"reinstall": "Reinstall Service Worker",
"reinstallError": "Error reinstalling Service Worker",
"entry": "entry",
"entries": "entries",
"loadingEntries": "Loading entries...",

View File

@@ -146,6 +146,8 @@
"imagesQuota": "{used}% du quota images utilisé",
"static": "Ressources statiques",
"staticDesc": "Scripts, styles et assets Next.js",
"pages": "Pages visitées",
"pagesDesc": "Home, bibliothèques, séries et détails",
"api": "Données API",
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
"images": "Images",
@@ -157,6 +159,8 @@
"clearedDesc": "Le cache a été vidé avec succès",
"clearError": "Erreur lors du vidage du cache",
"unavailable": "Statistiques du cache non disponibles",
"reinstall": "Réinstaller le Service Worker",
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
"entry": "entrée",
"entries": "entrées",
"loadingEntries": "Chargement des entrées...",