fix: improve service worker offline flow and dev toggle UX

This commit is contained in:
2026-03-01 12:47:58 +01:00
parent 844cd3f58e
commit 5a3b0ace61
9 changed files with 176 additions and 22 deletions

View File

@@ -44,6 +44,30 @@ function isBookPageRequest(url) {
);
}
function shouldCacheResponse(response) {
if (!response || !response.ok) return false;
const cacheControl = response.headers.get("Cache-Control") || "";
return !/no-store|private/i.test(cacheControl);
}
async function getOfflineFallbackResponse() {
// Try root page first for app-shell style recovery
const pagesCache = await caches.open(PAGES_CACHE);
const rootPage = await pagesCache.match("/");
if (rootPage) {
return rootPage;
}
// Last resort: static offline page
const staticCache = await caches.open(STATIC_CACHE);
const offlinePage = await staticCache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
return null;
}
// ============================================================================
// Client Communication
// ============================================================================
@@ -106,7 +130,7 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
try {
const response = await fetch(request);
if (response.ok) {
if (shouldCacheResponse(response)) {
cache.put(request, response.clone());
}
return response;
@@ -144,7 +168,7 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
// Start network request (don't await)
const fetchPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
if (shouldCacheResponse(response)) {
// Clone response for cache
const responseToCache = response.clone();
@@ -198,6 +222,14 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
return response;
}
if (options.fallbackToOffline) {
// For RSC/data requests, return an HTML fallback to avoid hard failures offline
const fallbackResponse = await getOfflineFallbackResponse();
if (fallbackResponse) {
return fallbackResponse;
}
}
throw new Error("Network failed and no cache available");
}
@@ -213,7 +245,7 @@ async function navigationSWRStrategy(request, cacheName) {
// Start network request in background
const fetchPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
if (shouldCacheResponse(response)) {
await cache.put(request, response.clone());
}
return response;
@@ -231,18 +263,10 @@ async function navigationSWRStrategy(request, cacheName) {
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;
// Network failed and no cache - try shared fallbacks
const fallbackResponse = await getOfflineFallbackResponse();
if (fallbackResponse) {
return fallbackResponse;
}
throw new Error("Offline and no cached page available");
@@ -264,7 +288,6 @@ self.addEventListener("install", (event) => {
// eslint-disable-next-line no-console
console.log("[SW] Precached assets");
} catch (error) {
console.error("[SW] Precache failed:", error);
}
await self.skipWaiting();
@@ -507,6 +530,7 @@ self.addEventListener("fetch", (event) => {
event.respondWith(
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
notifyOnChange: false,
fallbackToOffline: true,
})
);
return;

View File

@@ -8,6 +8,8 @@ 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 { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -197,12 +199,15 @@ export function CacheSettings() {
const {
isSupported,
isReady,
isDevModeEnabled,
version,
getCacheStats,
getCacheEntries,
clearCache,
reinstallServiceWorker,
setDevModeEnabled,
} = useServiceWorker();
const isDevelopment = process.env.NODE_ENV === "development";
const [stats, setStats] = useState<CacheStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -276,6 +281,25 @@ export function CacheSettings() {
}
};
const handleServiceWorkerDevToggle = async (checked: boolean) => {
try {
const success = await setDevModeEnabled(checked);
if (!success) {
throw new Error("Failed to toggle service worker in development");
}
toast({
title: t("settings.title"),
description: t("settings.cache.devServiceWorker.saved"),
});
} catch {
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.cache.devServiceWorker.error"),
});
}
};
// 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 +352,22 @@ export function CacheSettings() {
<CardDescription>{t("settings.cache.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isDevelopment && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="dev-sw-toggle">{t("settings.cache.devServiceWorker.label")}</Label>
<p className="text-sm text-muted-foreground">
{t("settings.cache.devServiceWorker.description")}
</p>
</div>
<Switch
id="dev-sw-toggle"
checked={isDevModeEnabled}
onCheckedChange={handleServiceWorkerDevToggle}
/>
</div>
)}
{/* Barre de progression globale */}
{stats && (
<div className="space-y-2">

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import type { KomgaConfig } from "@/types/komga";
import type { KomgaLibrary } from "@/types/komga";
import { useTranslate } from "@/hooks/useTranslate";
@@ -16,15 +17,35 @@ interface ClientSettingsProps {
initialLibraries: KomgaLibrary[];
}
const SETTINGS_TAB_STORAGE_KEY = "stripstream:settings-active-tab";
export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) {
const { t } = useTranslate();
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
useEffect(() => {
const savedTab = window.sessionStorage.getItem(SETTINGS_TAB_STORAGE_KEY);
if (savedTab === "display" || savedTab === "connection") {
const rafId = window.requestAnimationFrame(() => {
setActiveTab(savedTab);
});
return () => window.cancelAnimationFrame(rafId);
}
}, []);
const handleTabChange = (tab: string) => {
if (tab === "display" || tab === "connection") {
setActiveTab(tab);
window.sessionStorage.setItem(SETTINGS_TAB_STORAGE_KEY, tab);
}
};
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-8">
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<Tabs defaultValue="display" className="w-full">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="display" className="flex items-center gap-2">
<Monitor className="h-4 w-4" />

View File

@@ -9,7 +9,7 @@ export function NetworkStatus() {
if (isOnline) return null;
return (
<div className="fixed bottom-4 left-4 z-[100] flex items-center gap-2 rounded-lg bg-destructive/90 backdrop-blur-md px-4 py-2 text-sm text-destructive-foreground shadow-lg">
<div className="fixed right-4 top-[calc(4.5rem+env(safe-area-inset-top,0px))] z-[110] flex items-center gap-2 rounded-full border border-destructive/40 bg-destructive/90 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-destructive-foreground shadow-lg backdrop-blur-md animate-in fade-in slide-in-from-top-1">
<WifiOff className="h-4 w-4" />
<span>Hors ligne</span>
</div>

View File

@@ -2,7 +2,12 @@
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
import {
registerServiceWorker,
unregisterServiceWorker,
isServiceWorkerEnabledInDev,
setServiceWorkerEnabledInDev,
} from "@/lib/registerSW";
import logger from "@/lib/logger";
interface CacheStats {
@@ -29,6 +34,7 @@ type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
interface ServiceWorkerContextValue {
isSupported: boolean;
isReady: boolean;
isDevModeEnabled: boolean;
version: string | null;
hasNewVersion: boolean;
cacheUpdates: CacheUpdate[];
@@ -40,6 +46,7 @@ interface ServiceWorkerContextValue {
skipWaiting: () => void;
reloadForUpdate: () => void;
reinstallServiceWorker: () => Promise<boolean>;
setDevModeEnabled: (enabled: boolean) => Promise<boolean>;
}
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
@@ -47,6 +54,7 @@ const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(nul
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
const [isSupported, setIsSupported] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isDevModeEnabled, setIsDevModeEnabled] = useState(process.env.NODE_ENV !== "development");
const [version, setVersion] = useState<string | null>(null);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
@@ -159,7 +167,6 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
// 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") {
console.warn("[SW Context] Error handling message:", error, event.data);
}
}
@@ -172,8 +179,10 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
return;
}
if (process.env.NODE_ENV === "development") {
setIsSupported(false);
if (process.env.NODE_ENV === "development" && !isServiceWorkerEnabledInDev()) {
setIsDevModeEnabled(false);
// Browser still supports SW, it is only disabled by preference in dev
setIsSupported(true);
setIsReady(false);
setVersion(null);
@@ -184,6 +193,10 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
return;
}
if (process.env.NODE_ENV === "development") {
setIsDevModeEnabled(true);
}
setIsSupported(true);
// Register service worker
@@ -348,11 +361,37 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
}
}, []);
const setDevModeEnabled = useCallback(async (enabled: boolean): Promise<boolean> => {
if (process.env.NODE_ENV !== "development") {
return true;
}
try {
setServiceWorkerEnabledInDev(enabled);
setIsDevModeEnabled(enabled);
if (!enabled) {
await unregisterServiceWorker();
setIsSupported("serviceWorker" in navigator);
setIsReady(false);
setVersion(null);
setHasNewVersion(false);
}
window.location.reload();
return true;
} catch (error) {
logger.error({ err: error }, "Failed to toggle service worker in development");
return false;
}
}, []);
return (
<ServiceWorkerContext.Provider
value={{
isSupported,
isReady,
isDevModeEnabled,
version,
hasNewVersion,
cacheUpdates,
@@ -364,6 +403,7 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
skipWaiting,
reloadForUpdate,
reinstallServiceWorker,
setDevModeEnabled,
}}
>
{children}

View File

@@ -41,6 +41,10 @@ if (!i18n.isInitialized) {
transKeepBasicHtmlNodesFor: ["br", "strong", "i", "p", "span"], // Liste des balises autorisées
},
});
} else {
// Keep translation resources in sync during HMR/dev without full re-init.
i18n.addResourceBundle("fr", "common", frCommon, true, true);
i18n.addResourceBundle("en", "common", enCommon, true, true);
}
export default i18n;

View File

@@ -161,6 +161,12 @@
"unavailable": "Cache statistics unavailable",
"reinstall": "Reinstall Service Worker",
"reinstallError": "Error reinstalling Service Worker",
"devServiceWorker": {
"label": "Service Worker in development",
"description": "Enable Service Worker in dev mode to test cache/offline behavior. A reload is applied.",
"saved": "Dev Service Worker preference updated",
"error": "Failed to update dev Service Worker preference"
},
"entry": "entry",
"entries": "entries",
"loadingEntries": "Loading entries...",

View File

@@ -161,6 +161,12 @@
"unavailable": "Statistiques du cache non disponibles",
"reinstall": "Réinstaller le Service Worker",
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
"devServiceWorker": {
"label": "Service Worker en développement",
"description": "Active le Service Worker en mode dev pour tester le cache/hors-ligne. Un rechargement est appliqué.",
"saved": "Préférence Service Worker dev mise à jour",
"error": "Impossible de mettre à jour la préférence Service Worker dev"
},
"entry": "entrée",
"entries": "entrées",
"loadingEntries": "Chargement des entrées...",

View File

@@ -6,6 +6,19 @@ interface ServiceWorkerRegistrationOptions {
onError?: (error: Error) => void;
}
const DEV_SW_ENABLED_STORAGE_KEY = "stripstream:sw-dev-enabled";
export const isServiceWorkerEnabledInDev = (): boolean => {
if (typeof window === "undefined") return false;
if (process.env.NODE_ENV !== "development") return true;
return window.localStorage.getItem(DEV_SW_ENABLED_STORAGE_KEY) === "true";
};
export const setServiceWorkerEnabledInDev = (enabled: boolean): void => {
if (typeof window === "undefined" || process.env.NODE_ENV !== "development") return;
window.localStorage.setItem(DEV_SW_ENABLED_STORAGE_KEY, enabled ? "true" : "false");
};
/**
* Register the service worker with optional callbacks for update and success events
*/