From 508db98770f33fb77f4d8e24ba83a8434329e8c3 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 08:16:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ratewise):=20=E7=82=BA=E5=B0=8E?= =?UTF-8?q?=E8=A6=BD=20case-3=20=E5=8A=A0=E4=B8=8A=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E7=B6=B2=E8=B7=AF=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - precache 已驅逐時等網路設 8s 上限,避免連線掛住造成無限白屏 - timeout 後以 event.waitUntil 保留 in-flight fetch,成功仍寫入 html-cache - case 1 暖快取與 case 2 precache 命中維持零延遲不變 - 新增 handleNavigationRequest 單元測試覆蓋 case 2 即時與 case 3 逾時 fallback --- .changeset/ratewise-sw-bounded-nav.md | 5 + apps/ratewise/src/__tests__/sw.test.ts | 167 ++++++++++++++++++++++++- apps/ratewise/src/sw.ts | 17 ++- 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 .changeset/ratewise-sw-bounded-nav.md diff --git a/.changeset/ratewise-sw-bounded-nav.md b/.changeset/ratewise-sw-bounded-nav.md new file mode 100644 index 000000000..ec8b3accb --- /dev/null +++ b/.changeset/ratewise-sw-bounded-nav.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +離線/弱網下載入更穩定,不再可能卡白屏。 diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index df6959391..bcf3b5c4a 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -9,7 +9,52 @@ * [test:2026-01-10] PWA 離線功能測試 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; + +const { matchPrecacheMock, navigationHandlerRef } = vi.hoisted(() => ({ + matchPrecacheMock: vi.fn(), + navigationHandlerRef: { + current: null as + | ((params: { event: ExtendableEvent; request: Request }) => Promise) + | null, + }, +})); + +vi.mock('workbox-core', () => ({ + clientsClaim: vi.fn(), +})); + +vi.mock('workbox-precaching', () => ({ + cleanupOutdatedCaches: vi.fn(), + matchPrecache: (...args: unknown[]) => matchPrecacheMock(...args), + precacheAndRoute: vi.fn(), +})); + +vi.mock('workbox-routing', () => ({ + NavigationRoute: class NavigationRoute { + constructor( + handler: (params: { event: ExtendableEvent; request: Request }) => Promise, + ) { + navigationHandlerRef.current = handler; + } + }, + registerRoute: vi.fn(), + setCatchHandler: vi.fn(), +})); + +vi.mock('workbox-strategies', () => ({ + CacheFirst: class CacheFirst {}, + NetworkOnly: class NetworkOnly {}, + StaleWhileRevalidate: class StaleWhileRevalidate {}, +})); + +vi.mock('workbox-cacheable-response', () => ({ + CacheableResponsePlugin: class CacheableResponsePlugin {}, +})); + +vi.mock('workbox-expiration', () => ({ + ExpirationPlugin: class ExpirationPlugin {}, +})); // Mock ServiceWorkerGlobalScope const mockScope = 'https://example.com/ratewise/'; @@ -167,9 +212,12 @@ describe('Service Worker Cache Strategies', () => { expect(sourceCode).toContain("matchPrecache('index.html')"); // 防回歸:禁止重新引入 NetworkFirst navigation(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); - // 防回歸:禁止重新引入 3s timeout Promise.race(iOS eviction 假離線根因)。 + // 防回歸:禁止重新引入 3s 全域 navigation timeout(iOS eviction 假離線根因)。 expect(sourceCode).not.toContain('const NAVIGATION_NETWORK_TIMEOUT_MS'); expect(sourceCode).not.toContain('Promise.race([networkResponse, timeoutFallback])'); + // case 3(precache 已 miss)允許 8s bounded race,避免 hung network 無限白屏。 + expect(sourceCode).toContain('const NAVIGATION_FETCH_TIMEOUT_MS = 8000'); + expect(sourceCode).toContain('navigation-fetch-timeout'); }); it('should have correct historical rates cache configuration', () => { @@ -397,3 +445,118 @@ describe('Service Worker Denylist', () => { expect(isDenied('/faq')).toBe(false); }); }); + +describe('handleNavigationRequest', () => { + const htmlCacheName = 'html-cache'; + const navigationUrl = 'https://example.com/ratewise/about'; + const offlineHtml = 'offline fallback'; + + let htmlCache: { + match: ReturnType; + put: ReturnType; + }; + let cachesOpen: ReturnType; + let cachesMatch: ReturnType; + + beforeAll(async () => { + htmlCache = { + match: vi.fn(), + put: vi.fn(), + }; + cachesOpen = vi.fn().mockResolvedValue(htmlCache); + cachesMatch = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal('caches', { + open: cachesOpen, + match: cachesMatch, + keys: vi.fn().mockResolvedValue([]), + delete: vi.fn(), + }); + + await import('../sw.ts'); + expect(navigationHandlerRef.current).not.toBeNull(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + cachesOpen.mockResolvedValue(htmlCache); + cachesMatch.mockResolvedValue(undefined); + htmlCache.match.mockReset(); + htmlCache.put.mockReset(); + matchPrecacheMock.mockReset(); + }); + + function createNavigationEvent(): ExtendableEvent { + return { waitUntil: vi.fn() } as unknown as ExtendableEvent; + } + + function createOfflineFallbackResponse(): Response { + return new Response(offlineHtml, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + it('case 2: precache hit resolves instantly without timer dependency', async () => { + vi.useFakeTimers(); + + const precachedShell = new Response('precached index', { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation(async (url: string) => + url === 'index.html' ? precachedShell : null, + ); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + + const handler = navigationHandlerRef.current!; + const response = await handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + + expect(response).toBe(precachedShell); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + await vi.runAllTimersAsync(); + }); + + it('case 3: hung network falls back to offline.html after bounded timeout', async () => { + vi.useFakeTimers(); + + const offlineFallback = createOfflineFallbackResponse(); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation(async (url: string) => { + if (url === 'index.html') return null; + if (url === 'offline.html') return offlineFallback; + return null; + }); + cachesMatch.mockResolvedValue(undefined); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + + const handler = navigationHandlerRef.current!; + const responsePromise = handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + + await vi.advanceTimersByTimeAsync(8000); + + const response = await responsePromise; + const body = await response.text(); + + expect(body).toBe(offlineHtml); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + expect(matchPrecacheMock).toHaveBeenCalledWith('offline.html'); + expect(cachesOpen).toHaveBeenCalledWith(htmlCacheName); + }); +}); diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index 53ee30d95..d8475638a 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -28,6 +28,7 @@ self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { // 保存 manifest 供 VERIFY_AND_REPAIR_PRECACHE 使用。 const WB_MANIFEST = self.__WB_MANIFEST; const HTML_CACHE_NAME = 'html-cache'; +const NAVIGATION_FETCH_TIMEOUT_MS = 8000; // 預快取 Vite 產出的靜態資源。 precacheAndRoute(WB_MANIFEST); @@ -340,10 +341,22 @@ async function handleNavigationRequest({ return precachedShell; } - // precache 也 miss(例如 iOS eviction):等網路,真正失敗才用 offline fallback + // precache 也 miss(iOS eviction):等網路但設上限,避免連線掛住造成無限白屏。 + // 此分支 precache 已確認不存在,timeout fallback 不會把 offline.html 誤送給 precache 仍在的在線用戶。 + const networkFetch = fetchAndCacheNavigation(request, cache); try { - return await fetchAndCacheNavigation(request, cache); + return await Promise.race([ + networkFetch, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('navigation-fetch-timeout')), + NAVIGATION_FETCH_TIMEOUT_MS, + ), + ), + ]); } catch { + // 讓 in-flight fetch 繼續,成功則寫入 html-cache 供下次導覽。 + event.waitUntil(networkFetch.then(() => undefined).catch(() => undefined)); return resolveNavigationFallback(); } } From fbc1d48aff3ac6f4f3078d424de96fc63c0683dd Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 08:31:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ratewise):=20=E4=BF=AE=E6=AD=A3=20sw=20?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=20mock=20=E7=9A=84=20require-await=20lint=20?= =?UTF-8?q?=E9=8C=AF=E8=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 將 matchPrecache mock 由 async 改為回傳 Promise.resolve,消除無 await 的 async - 不影響 case-2/case-3 測試行為 測試:vitest run src/__tests__/sw.test.ts 37/37 通過;eslint 該檔 0 錯誤 --- apps/ratewise/src/__tests__/sw.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index bcf3b5c4a..17e34de00 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -507,8 +507,8 @@ describe('handleNavigationRequest', () => { }); htmlCache.match.mockResolvedValue(undefined); - matchPrecacheMock.mockImplementation(async (url: string) => - url === 'index.html' ? precachedShell : null, + matchPrecacheMock.mockImplementation((url: string) => + Promise.resolve(url === 'index.html' ? precachedShell : null), ); vi.stubGlobal( 'fetch', @@ -532,10 +532,10 @@ describe('handleNavigationRequest', () => { const offlineFallback = createOfflineFallbackResponse(); htmlCache.match.mockResolvedValue(undefined); - matchPrecacheMock.mockImplementation(async (url: string) => { - if (url === 'index.html') return null; - if (url === 'offline.html') return offlineFallback; - return null; + matchPrecacheMock.mockImplementation((url: string) => { + if (url === 'index.html') return Promise.resolve(null); + if (url === 'offline.html') return Promise.resolve(offlineFallback); + return Promise.resolve(null); }); cachesMatch.mockResolvedValue(undefined); vi.stubGlobal(