fix: improve service worker offline flow and dev toggle UX
This commit is contained in:
56
public/sw.js
56
public/sw.js
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user