From 0bc7e4c18d46d632d29992f92d53a33b24caf83f Mon Sep 17 00:00:00 2001 From: haotool Date: Sat, 27 Jun 2026 03:34:59 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(ratewise):=20=E7=A7=BB=E6=A4=8D?= =?UTF-8?q?=E5=B0=8E=E8=A6=BD=20case-3=20=E6=9C=89=E7=95=8C=E7=B6=B2?= =?UTF-8?q?=E8=B7=AF=20fallback=20=E8=87=B3=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - precache miss 時以 8 秒 Promise.race 限制網路等待,逾時回 offline shell - 逾時後以 event.waitUntil 保留 in-flight fetch,成功仍寫入 html-cache - 僅在 precache miss 後套用,不重新引入全域 3 秒逾時 - 補 case-2/case-3 行為測試與防回歸斷言 測試:vitest sw.test.ts 36 項通過、tsc --noEmit 通過 --- .changeset/ratewise-sw-bounded-nav.md | 5 + apps/ratewise/src/__tests__/sw.test.ts | 169 ++++++++++++++++++++++++- apps/ratewise/src/sw.ts | 15 ++- 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..84979a2ee --- /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 50c167314..988ef9d5d 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/'; @@ -166,7 +211,12 @@ describe('Service Worker Cache Strategies', () => { expect(sourceCode).toContain("matchPrecache('index.html')"); // 防回歸:禁止重新引入 NetworkFirst navigation(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); - expect(sourceCode).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); + // 防回歸:禁止重新引入 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', () => { @@ -381,3 +431,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((url: string) => + Promise.resolve(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((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( + '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 9616967f2..5838e8bf4 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -31,6 +31,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); @@ -312,10 +313,20 @@ async function handleNavigationRequest({ return precachedShell; } - // precache 也 miss(例如 iOS eviction):等網路,真正失敗才用 offline fallback + // precache 也 miss(iOS eviction):等網路但設上限,避免連線掛住造成無限白屏。 + 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 { + event.waitUntil(networkFetch.then(() => undefined).catch(() => undefined)); return resolveNavigationFallback(); } } From b03c5fda369d320da102902980ee58d92f5309f7 Mon Sep 17 00:00:00 2001 From: haotool Date: Sat, 27 Jun 2026 11:52:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ratewise):=20=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E5=B0=8E=E8=A6=BD=E9=80=BE=E6=99=82=E8=A8=88=E6=99=82=E5=99=A8?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20orphan=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 網路先成功時 clearTimeout,避免逾時計時器稍後 reject 成 unhandled rejection - 補網路成功回傳 network response 與晚成功仍寫入 html-cache 的測試 測試:sw.test.ts 38 項通過、tsc 通過 --- .changeset/ratewise-sw-bounded-nav.md | 2 + apps/ratewise/src/__tests__/sw.test.ts | 90 ++++++++++++++++++++++++++ apps/ratewise/src/sw.ts | 13 ++-- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/.changeset/ratewise-sw-bounded-nav.md b/.changeset/ratewise-sw-bounded-nav.md index 84979a2ee..3858169f4 100644 --- a/.changeset/ratewise-sw-bounded-nav.md +++ b/.changeset/ratewise-sw-bounded-nav.md @@ -3,3 +3,5 @@ --- 離線/弱網下導覽不再被卡住的網路請求拖住,避免長時間白屏。 + +- 網路成功時清除 8 秒 race timer,避免 orphan rejection 與 timer 洩漏。 diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index 988ef9d5d..e2402a8dd 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -512,6 +512,96 @@ describe('handleNavigationRequest', () => { await vi.runAllTimersAsync(); }); + it('case 3: network resolves within 8s returns network response without orphan timer', async () => { + vi.useFakeTimers(); + + const networkHtml = 'network fresh'; + const networkResponse = new Response(networkHtml, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation((url: string) => + Promise.resolve(url === 'index.html' ? null : null), + ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(networkResponse.clone())); + + const orphanRejections: unknown[] = []; + const onRejection = (reason: unknown) => { + orphanRejections.push(reason); + }; + process.on('unhandledRejection', onRejection); + + try { + const handler = navigationHandlerRef.current!; + const response = await handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + const body = await response.text(); + + expect(body).toBe(networkHtml); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + expect(matchPrecacheMock).not.toHaveBeenCalledWith('offline.html'); + + await vi.advanceTimersByTimeAsync(8000); + expect(orphanRejections).toHaveLength(0); + } finally { + process.off('unhandledRejection', onRejection); + } + }); + + it('case 3: timeout fallback still waitUntils late network fetch to html-cache', async () => { + vi.useFakeTimers(); + + const offlineFallback = createOfflineFallbackResponse(); + const networkHtml = 'late network'; + let resolveFetch!: (value: Response) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation((url: string) => { + if (url === 'index.html') return Promise.resolve(null); + if (url === 'offline.html') return Promise.resolve(offlineFallback); + return Promise.resolve(null); + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => fetchPromise), + ); + + const event = createNavigationEvent(); + const handler = navigationHandlerRef.current!; + const responsePromise = handler({ + event, + request: new Request(navigationUrl), + }); + + await vi.advanceTimersByTimeAsync(8000); + + const response = await responsePromise; + const body = await response.text(); + + expect(body).toBe(offlineHtml); + expect(event.waitUntil).toHaveBeenCalledTimes(1); + + resolveFetch( + new Response(networkHtml, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }), + ); + const waitUntilPromise = (event.waitUntil as ReturnType).mock.calls[0]?.[0] as + | Promise + | undefined; + await waitUntilPromise; + + expect(htmlCache.put).toHaveBeenCalled(); + }); + it('case 3: hung network falls back to offline.html after bounded timeout', async () => { vi.useFakeTimers(); diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index 5838e8bf4..22c4261d7 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -315,19 +315,24 @@ async function handleNavigationRequest({ // precache 也 miss(iOS eviction):等網路但設上限,避免連線掛住造成無限白屏。 const networkFetch = fetchAndCacheNavigation(request, cache); + let timeoutId: ReturnType | undefined; try { return await Promise.race([ networkFetch, - new Promise((_, reject) => - setTimeout( + new Promise((_, reject) => { + timeoutId = setTimeout( () => reject(new Error('navigation-fetch-timeout')), NAVIGATION_FETCH_TIMEOUT_MS, - ), - ), + ); + }), ]); } catch { event.waitUntil(networkFetch.then(() => undefined).catch(() => undefined)); return resolveNavigationFallback(); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } From abb203fd569cbd61148decd778a2aee299b5f627 Mon Sep 17 00:00:00 2001 From: haotool Date: Sat, 27 Jun 2026 12:58:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ratewise):=20=E4=BF=AE=E6=AD=A3=20sw=20?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=20unbound-method=20lint=20=E9=8C=AF=E8=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 將 mock 方法擷取為區域變數後再斷言,消除 @typescript-eslint/unbound-method 測試:sw.test.ts 通過、pnpm lint 通過、tsc 通過 --- apps/ratewise/src/__tests__/sw.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index e2402a8dd..4151362e5 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -573,7 +573,8 @@ describe('handleNavigationRequest', () => { vi.fn(() => fetchPromise), ); - const event = createNavigationEvent(); + const waitUntilMock = vi.fn(); + const event = { waitUntil: waitUntilMock } as unknown as ExtendableEvent; const handler = navigationHandlerRef.current!; const responsePromise = handler({ event, @@ -586,7 +587,7 @@ describe('handleNavigationRequest', () => { const body = await response.text(); expect(body).toBe(offlineHtml); - expect(event.waitUntil).toHaveBeenCalledTimes(1); + expect(waitUntilMock).toHaveBeenCalledTimes(1); resolveFetch( new Response(networkHtml, { @@ -594,9 +595,7 @@ describe('handleNavigationRequest', () => { headers: { 'Content-Type': 'text/html; charset=utf-8' }, }), ); - const waitUntilPromise = (event.waitUntil as ReturnType).mock.calls[0]?.[0] as - | Promise - | undefined; + const waitUntilPromise = waitUntilMock.mock.calls[0]?.[0] as Promise | undefined; await waitUntilPromise; expect(htmlCache.put).toHaveBeenCalled();