diff --git a/.changeset/ratewise-sw-static-fallback-ssot.md b/.changeset/ratewise-sw-static-fallback-ssot.md new file mode 100644 index 000000000..23e2b38e2 --- /dev/null +++ b/.changeset/ratewise-sw-static-fallback-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +強化離線可靠度:Service Worker 在網路失敗時,對 JS/CSS 資源改用三層快取回退(精確網址 → 忽略查詢字串 → precache 比對),降低 iOS 在 cache 驅逐後出現「Load failed」白屏的機率。離線文件回退與靜態資源回退邏輯收斂為單一來源。 diff --git a/apps/ratewise/src/features/ratewise/storage-keys.ts b/apps/ratewise/src/features/ratewise/storage-keys.ts index f25fbee30..319d61f3c 100644 --- a/apps/ratewise/src/features/ratewise/storage-keys.ts +++ b/apps/ratewise/src/features/ratewise/storage-keys.ts @@ -18,9 +18,12 @@ * 2. major version bump * 3. release notes 明確告知使用者 * - * 參考:/home/user/app/CLAUDE.md + * 參考:`CLAUDE.md`、`AGENTS.md` */ +/** Zustand converterStore persist key(與 USER_DATA_KEYS 同步,不可改名)。 */ +export const CONVERTER_STORE_KEY = 'ratewise-converter' as const; + /** * localStorage Keys 常數 */ @@ -101,7 +104,7 @@ export const CACHE_KEY_PREFIXES = [STORAGE_KEYS.EXCHANGE_SHOP_RATE_PREFIX] as co * 仍保留在本清單中以保護過渡期使用者資料不被快取清除流程誤刪。 */ export const USER_DATA_KEYS = [ - 'ratewise-converter', // Zustand store(含 fromCurrency/toCurrency/mode/rateMode/rateType/rateSource/favorites/history) + CONVERTER_STORE_KEY, STORAGE_KEYS.RATE_TYPE, STORAGE_KEYS.RATE_SOURCE, STORAGE_KEYS.CONVERSION_HISTORY, diff --git a/apps/ratewise/src/services/exchangeRateService.ts b/apps/ratewise/src/services/exchangeRateService.ts index 9ab5933a8..a0b4197cd 100644 --- a/apps/ratewise/src/services/exchangeRateService.ts +++ b/apps/ratewise/src/services/exchangeRateService.ts @@ -37,13 +37,6 @@ const CDN_URLS = [ // 單次 CDN fetch 逾時上限。行動網路平均 RTT 約 200-500ms,8 秒足以涵蓋 3G 網路,同時防止無限等待。 export const FETCH_TIMEOUT_MS = 8_000; -// localStorage key 分離策略: -// 使用 storage-keys.ts 集中管理所有 localStorage keys -// - STORAGE_KEYS.EXCHANGE_RATES: 匯率數據快取(本檔案管理,5分鐘過期) -// - STORAGE_KEYS.CURRENCY_CONVERTER_MODE: 用戶界面模式(RateWise.tsx) -// - STORAGE_KEYS.FAVORITES: 用戶收藏的貨幣(RateWise.tsx) -// - STORAGE_KEYS.FROM_CURRENCY, TO_CURRENCY: 用戶選擇的貨幣(RateWise.tsx) -// clearExchangeRateCache() 只清除快取,不影響用戶數據 const CACHE_KEY = STORAGE_KEYS.EXCHANGE_RATES; const CACHE_DURATION = 5 * 60 * 1000; // 5 分鐘 const IS_LHCI_OFFLINE = import.meta.env['VITE_LHCI_OFFLINE'] === 'true'; @@ -166,9 +159,6 @@ async function fetchFromCDN(signal?: AbortSignal): Promise { try { logger.debug(`Trying CDN #${i + 1}/${CDN_URLS.length}`, { url: url.substring(0, 80) }); - // [2026-06-12] 不發送 If-None-Match:該 header 非 CORS safelisted, - // jsDelivr preflight 不允許,會使主 CDN 永遠失敗並降級到 GitHub Raw(60 req/hr)。 - // TTL 到期後以 cache: 'no-cache' 強制 CDN 重新驗證。 const fetchInit: RequestInit = { cache: 'no-cache', ...(signal ? { signal } : {}), diff --git a/apps/ratewise/src/stores/converterStore.ts b/apps/ratewise/src/stores/converterStore.ts index a67fc839d..9322eaa05 100644 --- a/apps/ratewise/src/stores/converterStore.ts +++ b/apps/ratewise/src/stores/converterStore.ts @@ -47,7 +47,7 @@ import { RATE_SOURCES, RATE_TYPES, } from '../features/ratewise/constants'; -import { STORAGE_KEYS } from '../features/ratewise/storage-keys'; +import { CONVERTER_STORE_KEY, STORAGE_KEYS } from '../features/ratewise/storage-keys'; const DEFAULT_PROVIDER_PREFERENCE: RateProviderPreference = { mode: 'manual', @@ -289,7 +289,7 @@ function buildMigrationPatch(state: ConverterState): Partial | }; } - if (window.localStorage.getItem('ratewise-converter') === null) { + if (window.localStorage.getItem(CONVERTER_STORE_KEY) === null) { const oldFrom = window.localStorage.getItem(LEGACY_KEYS.FROM_CURRENCY); const oldTo = window.localStorage.getItem(LEGACY_KEYS.TO_CURRENCY); const oldFavoritesRaw = window.localStorage.getItem(LEGACY_KEYS.FAVORITES); @@ -479,7 +479,7 @@ export const useConverterStore = create()( }, }), { - name: 'ratewise-converter', + name: CONVERTER_STORE_KEY, partialize: (state) => ({ lastConverterView: state.lastConverterView, fromCurrency: state.fromCurrency, diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index 13fd81312..9616967f2 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -8,7 +8,10 @@ import { NavigationRoute, registerRoute, setCatchHandler } from 'workbox-routing import { CacheFirst, NetworkOnly, StaleWhileRevalidate } from 'workbox-strategies'; import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { ExpirationPlugin } from 'workbox-expiration'; -import { resolveOfflineDocumentFallback } from './utils/pwaOfflineFallback'; +import { + resolveOfflineDocumentFallback, + resolveOfflineStaticResourceFallback, +} from './utils/pwaOfflineFallback'; declare const self: ServiceWorkerGlobalScope & typeof globalThis; @@ -33,25 +36,8 @@ const HTML_CACHE_NAME = 'html-cache'; precacheAndRoute(WB_MANIFEST); /** - * 確保 offline.html 一定被快取(install 失敗時的補救機制)。 - * - * iOS Safari 在 PWA 冷啟動時可能遇到以下問題: - * 1. SW install 過程中 offline.html 的 precache 因網路問題失敗 - * 2. additionalManifestEntries 的 revision 導致 cache key mismatch - * 3. iOS cache eviction 清除了 offline.html - * - * 此機制在 SW activate 時直接用 bare URL(無 revision)快取 offline.html, - * 確保 setCatchHandler 的 matchPrecache('offline.html') 或 caches.match() 一定能命中。 - */ -/** - * 確保關鍵文件存入 html-cache,對抗 iOS Safari precache 驅逐。 - * - * 問題:iOS 在記憶體壓力下驅逐 Workbox precache(整個 cache 被砍), - * 造成 matchPrecache('index.html') 失敗,而 offline.html 若只在 html-cache - * 中備份則反而成為唯一可用文件,導致在線用戶看到離線頁面。 - * - * 修法:offline.html 與 index.html 都備份到 html-cache,確保兩者具有 - * 同等存活率。index.html 從 precache 複製(避免額外網路請求)。 + * 確保 index.html / offline.html 存入 html-cache,對抗 iOS precache 驅逐。 + * offline.html 從網路備份;index.html 從 precache 複製,避免在線用戶只剩離線頁。 */ async function ensureOfflineHtmlCached(): Promise { try { @@ -144,12 +130,7 @@ async function clearNavigationHtmlCacheOnActivate(): Promise { } } -/** - * Cache Budget Guard(PR3: iOS 50MB cache 限制保護) - * - * iOS Safari 對 SW cache 有 50MB 上限,超過會觸發 eviction。 - * 此函式在 install 時檢查使用量,超過 40MB 時清理非關鍵快取。 - */ +/** iOS Safari SW cache 約 50MB 上限;超過 40MB 時清理非關鍵快取。 */ async function checkAndCleanupCacheBudget(): Promise { try { if (!navigator.storage?.estimate) return; @@ -161,7 +142,6 @@ async function checkAndCleanupCacheBudget(): Promise { const budgetMB = 40; // iOS 安全邊界(50MB 上限前預留緩衝) if (usageMB <= budgetMB) { - // Cache 使用量在預算內,無需清理 return; } @@ -177,10 +157,8 @@ async function checkAndCleanupCacheBudget(): Promise { await caches.delete(cacheName); console.warn(`[SW] Deleted cache: ${cacheName}`); - // 重新檢查使用量 const { usage: newUsage } = await navigator.storage.estimate(); if (newUsage && newUsage / 1024 / 1024 <= budgetMB) { - // 清理完成,使用量已在預算內 return; } } @@ -245,32 +223,28 @@ registerRoute( new NetworkOnly(), ); -// 網路失敗離線回退:JS/CSS 嘗試快取;導覽請求回退至 precache index.html / offline.html。 +const OFFLINE_DOCUMENT_FALLBACK_MATCHERS = { + matchPrecache, + matchIndexHtmlInAnyCache: () => caches.match('index.html'), + matchOfflineHtmlInAnyCache: () => caches.match('offline.html'), +} as const; + +// 網路失敗離線回退:JS/CSS 三層快取;導覽請求回退至 precache index.html / offline.html。 setCatchHandler(async ({ event, request }): Promise => { const fetchEvent = event as FetchEvent; const req = request ?? fetchEvent.request; - // JS/CSS 離線回退:iOS cache eviction 後先嘗試全快取比對,避免 "Load failed" 崩潰。 if (req.destination === 'script' || req.destination === 'style') { - try { - const cached = await caches.match(req); - if (cached) return cached; - } catch { - // 忽略快取存取錯誤。 - } - return Response.error(); + return resolveOfflineStaticResourceFallback(req, matchPrecache); } if (req.destination !== 'document') { return Response.error(); } - // NavigationRoute 已攔截正常導覽;setCatchHandler 僅在網路失敗時作保險層。 return resolveOfflineDocumentFallback({ emergencyReason: 'emergency-document-fallback', - matchPrecache, - matchIndexHtmlInAnyCache: () => caches.match('index.html'), - matchOfflineHtmlInAnyCache: () => caches.match('offline.html'), + ...OFFLINE_DOCUMENT_FALLBACK_MATCHERS, }); }); @@ -291,9 +265,7 @@ setCatchHandler(async ({ event, request }): Promise => { function resolveNavigationFallback(): Promise { return resolveOfflineDocumentFallback({ emergencyReason: 'emergency-navigation-fallback', - matchPrecache, - matchIndexHtmlInAnyCache: () => caches.match('index.html'), - matchOfflineHtmlInAnyCache: () => caches.match('offline.html'), + ...OFFLINE_DOCUMENT_FALLBACK_MATCHERS, }); } diff --git a/apps/ratewise/src/utils/pwaOfflineFallback.ts b/apps/ratewise/src/utils/pwaOfflineFallback.ts index edd6342bb..117767423 100644 --- a/apps/ratewise/src/utils/pwaOfflineFallback.ts +++ b/apps/ratewise/src/utils/pwaOfflineFallback.ts @@ -85,6 +85,59 @@ interface ResolveOfflineDocumentFallbackOptions { | Promise; } +function getPrecacheLookupKeys(request: Request): string[] { + const pathname = new URL(request.url).pathname; + const keys = new Set(); + const withoutLeadingSlash = pathname.replace(/^\//, ''); + if (withoutLeadingSlash) keys.add(withoutLeadingSlash); + + const assetsIndex = pathname.indexOf('/assets/'); + if (assetsIndex >= 0) { + keys.add(pathname.slice(assetsIndex + 1)); + } + + const filename = pathname.split('/').pop(); + if (filename) keys.add(filename); + + return [...keys]; +} + +type MatchPrecacheFn = ( + url: string, +) => Response | undefined | null | Promise; + +/** JS/CSS 離線回退:exact URL → ignoreSearch → matchPrecache。 */ +export async function resolveOfflineStaticResourceFallback( + request: Request, + matchPrecacheFn: MatchPrecacheFn, +): Promise { + try { + const exact = await caches.match(request); + if (exact) return exact; + } catch { + // 忽略快取存取錯誤。 + } + + const bareUrl = request.url.split('?')[0] ?? request.url; + try { + const ignoreSearchMatch = await caches.match(bareUrl, { ignoreSearch: true }); + if (ignoreSearchMatch) return ignoreSearchMatch; + } catch { + // 忽略快取存取錯誤。 + } + + for (const key of getPrecacheLookupKeys(request)) { + try { + const precached = await matchPrecacheFn(key); + if (precached) return precached; + } catch { + // 忽略 matchPrecache 錯誤。 + } + } + + return Response.error(); +} + export async function resolveOfflineDocumentFallback({ emergencyReason, matchPrecache,