Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ratewise-sw-static-fallback-ssot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': patch
---

強化離線可靠度:Service Worker 在網路失敗時,對 JS/CSS 資源改用三層快取回退(精確網址 → 忽略查詢字串 → precache 比對),降低 iOS 在 cache 驅逐後出現「Load failed」白屏的機率。離線文件回退與靜態資源回退邏輯收斂為單一來源。
7 changes: 5 additions & 2 deletions apps/ratewise/src/features/ratewise/storage-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 常數
*/
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 0 additions & 10 deletions apps/ratewise/src/services/exchangeRateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -166,9 +159,6 @@ async function fetchFromCDN(signal?: AbortSignal): Promise<FetchResult> {
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 } : {}),
Expand Down
6 changes: 3 additions & 3 deletions apps/ratewise/src/stores/converterStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -289,7 +289,7 @@ function buildMigrationPatch(state: ConverterState): Partial<PersistentFields> |
};
}

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);
Expand Down Expand Up @@ -479,7 +479,7 @@ export const useConverterStore = create<ConverterState>()(
},
}),
{
name: 'ratewise-converter',
name: CONVERTER_STORE_KEY,
partialize: (state) => ({
lastConverterView: state.lastConverterView,
fromCurrency: state.fromCurrency,
Expand Down
62 changes: 17 additions & 45 deletions apps/ratewise/src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<void> {
try {
Expand Down Expand Up @@ -144,12 +130,7 @@ async function clearNavigationHtmlCacheOnActivate(): Promise<void> {
}
}

/**
* 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<void> {
try {
if (!navigator.storage?.estimate) return;
Expand All @@ -161,7 +142,6 @@ async function checkAndCleanupCacheBudget(): Promise<void> {
const budgetMB = 40; // iOS 安全邊界(50MB 上限前預留緩衝)

if (usageMB <= budgetMB) {
// Cache 使用量在預算內,無需清理
return;
}

Expand All @@ -177,10 +157,8 @@ async function checkAndCleanupCacheBudget(): Promise<void> {
await caches.delete(cacheName);
console.warn(`[SW] Deleted cache: ${cacheName}`);

// 重新檢查使用量
const { usage: newUsage } = await navigator.storage.estimate();
if (newUsage && newUsage / 1024 / 1024 <= budgetMB) {
// 清理完成,使用量已在預算內
return;
}
}
Expand Down Expand Up @@ -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<Response> => {
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,
});
});

Expand All @@ -291,9 +265,7 @@ setCatchHandler(async ({ event, request }): Promise<Response> => {
function resolveNavigationFallback(): Promise<Response> {
return resolveOfflineDocumentFallback({
emergencyReason: 'emergency-navigation-fallback',
matchPrecache,
matchIndexHtmlInAnyCache: () => caches.match('index.html'),
matchOfflineHtmlInAnyCache: () => caches.match('offline.html'),
...OFFLINE_DOCUMENT_FALLBACK_MATCHERS,
});
}

Expand Down
53 changes: 53 additions & 0 deletions apps/ratewise/src/utils/pwaOfflineFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,59 @@ interface ResolveOfflineDocumentFallbackOptions {
| Promise<Response | undefined | null>;
}

function getPrecacheLookupKeys(request: Request): string[] {
const pathname = new URL(request.url).pathname;
const keys = new Set<string>();
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<Response | undefined | null>;

/** JS/CSS 離線回退:exact URL → ignoreSearch → matchPrecache。 */
export async function resolveOfflineStaticResourceFallback(
request: Request,
matchPrecacheFn: MatchPrecacheFn,
): Promise<Response> {
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,
Expand Down
Loading