Skip to content

Commit b615b4a

Browse files
s123104haotool
andauthored
fix(ratewise): 移植導覽 case-3 有界網路 fallback 至 main (#456)
* fix(ratewise): 移植導覽 case-3 有界網路 fallback 至 main - 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 通過 * fix(ratewise): 清除導覽逾時計時器避免 orphan rejection - 網路先成功時 clearTimeout,避免逾時計時器稍後 reject 成 unhandled rejection - 補網路成功回傳 network response 與晚成功仍寫入 html-cache 的測試 測試:sw.test.ts 38 項通過、tsc 通過 * fix(ratewise): 修正 sw 測試 unbound-method lint 錯誤 - 將 mock 方法擷取為區域變數後再斷言,消除 @typescript-eslint/unbound-method 測試:sw.test.ts 通過、pnpm lint 通過、tsc 通過 --------- Co-authored-by: haotool <haotool.org@gmail.com>
1 parent 567ebd4 commit b615b4a

3 files changed

Lines changed: 281 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@app/ratewise': patch
3+
---
4+
5+
離線/弱網下導覽不再被卡住的網路請求拖住,避免長時間白屏。
6+
7+
- 網路成功時清除 8 秒 race timer,避免 orphan rejection 與 timer 洩漏。

apps/ratewise/src/__tests__/sw.test.ts

Lines changed: 256 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,52 @@
99
* [test:2026-01-10] PWA 離線功能測試
1010
*/
1111

12-
import { describe, it, expect, vi } from 'vitest';
12+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
13+
14+
const { matchPrecacheMock, navigationHandlerRef } = vi.hoisted(() => ({
15+
matchPrecacheMock: vi.fn(),
16+
navigationHandlerRef: {
17+
current: null as
18+
| ((params: { event: ExtendableEvent; request: Request }) => Promise<Response>)
19+
| null,
20+
},
21+
}));
22+
23+
vi.mock('workbox-core', () => ({
24+
clientsClaim: vi.fn(),
25+
}));
26+
27+
vi.mock('workbox-precaching', () => ({
28+
cleanupOutdatedCaches: vi.fn(),
29+
matchPrecache: (...args: unknown[]) => matchPrecacheMock(...args),
30+
precacheAndRoute: vi.fn(),
31+
}));
32+
33+
vi.mock('workbox-routing', () => ({
34+
NavigationRoute: class NavigationRoute {
35+
constructor(
36+
handler: (params: { event: ExtendableEvent; request: Request }) => Promise<Response>,
37+
) {
38+
navigationHandlerRef.current = handler;
39+
}
40+
},
41+
registerRoute: vi.fn(),
42+
setCatchHandler: vi.fn(),
43+
}));
44+
45+
vi.mock('workbox-strategies', () => ({
46+
CacheFirst: class CacheFirst {},
47+
NetworkOnly: class NetworkOnly {},
48+
StaleWhileRevalidate: class StaleWhileRevalidate {},
49+
}));
50+
51+
vi.mock('workbox-cacheable-response', () => ({
52+
CacheableResponsePlugin: class CacheableResponsePlugin {},
53+
}));
54+
55+
vi.mock('workbox-expiration', () => ({
56+
ExpirationPlugin: class ExpirationPlugin {},
57+
}));
1358

1459
// Mock ServiceWorkerGlobalScope
1560
const mockScope = 'https://example.com/ratewise/';
@@ -166,7 +211,12 @@ describe('Service Worker Cache Strategies', () => {
166211
expect(sourceCode).toContain("matchPrecache('index.html')");
167212
// 防回歸:禁止重新引入 NetworkFirst navigation(cold-start 白屏根因之一)。
168213
expect(sourceCode).not.toContain('new NetworkFirst(');
169-
expect(sourceCode).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS');
214+
// 防回歸:禁止重新引入 3s 全域 navigation timeout(iOS eviction 假離線根因)。
215+
expect(sourceCode).not.toContain('const NAVIGATION_NETWORK_TIMEOUT_MS');
216+
expect(sourceCode).not.toContain('Promise.race([networkResponse, timeoutFallback])');
217+
// case 3(precache 已 miss)允許 8s bounded race,避免 hung network 無限白屏。
218+
expect(sourceCode).toContain('const NAVIGATION_FETCH_TIMEOUT_MS = 8000');
219+
expect(sourceCode).toContain('navigation-fetch-timeout');
170220
});
171221

172222
it('should have correct historical rates cache configuration', () => {
@@ -381,3 +431,207 @@ describe('Service Worker Denylist', () => {
381431
expect(isDenied('/faq')).toBe(false);
382432
});
383433
});
434+
435+
describe('handleNavigationRequest', () => {
436+
const htmlCacheName = 'html-cache';
437+
const navigationUrl = 'https://example.com/ratewise/about';
438+
const offlineHtml = '<html>offline fallback</html>';
439+
440+
let htmlCache: {
441+
match: ReturnType<typeof vi.fn>;
442+
put: ReturnType<typeof vi.fn>;
443+
};
444+
let cachesOpen: ReturnType<typeof vi.fn>;
445+
let cachesMatch: ReturnType<typeof vi.fn>;
446+
447+
beforeAll(async () => {
448+
htmlCache = {
449+
match: vi.fn(),
450+
put: vi.fn(),
451+
};
452+
cachesOpen = vi.fn().mockResolvedValue(htmlCache);
453+
cachesMatch = vi.fn().mockResolvedValue(undefined);
454+
455+
vi.stubGlobal('caches', {
456+
open: cachesOpen,
457+
match: cachesMatch,
458+
keys: vi.fn().mockResolvedValue([]),
459+
delete: vi.fn(),
460+
});
461+
462+
await import('../sw.ts');
463+
expect(navigationHandlerRef.current).not.toBeNull();
464+
});
465+
466+
afterEach(() => {
467+
vi.useRealTimers();
468+
vi.clearAllMocks();
469+
cachesOpen.mockResolvedValue(htmlCache);
470+
cachesMatch.mockResolvedValue(undefined);
471+
htmlCache.match.mockReset();
472+
htmlCache.put.mockReset();
473+
matchPrecacheMock.mockReset();
474+
});
475+
476+
function createNavigationEvent(): ExtendableEvent {
477+
return { waitUntil: vi.fn() } as unknown as ExtendableEvent;
478+
}
479+
480+
function createOfflineFallbackResponse(): Response {
481+
return new Response(offlineHtml, {
482+
status: 200,
483+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
484+
});
485+
}
486+
487+
it('case 2: precache hit resolves instantly without timer dependency', async () => {
488+
vi.useFakeTimers();
489+
490+
const precachedShell = new Response('<html>precached index</html>', {
491+
status: 200,
492+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
493+
});
494+
495+
htmlCache.match.mockResolvedValue(undefined);
496+
matchPrecacheMock.mockImplementation((url: string) =>
497+
Promise.resolve(url === 'index.html' ? precachedShell : null),
498+
);
499+
vi.stubGlobal(
500+
'fetch',
501+
vi.fn(() => new Promise<Response>(() => undefined)),
502+
);
503+
504+
const handler = navigationHandlerRef.current!;
505+
const response = await handler({
506+
event: createNavigationEvent(),
507+
request: new Request(navigationUrl),
508+
});
509+
510+
expect(response).toBe(precachedShell);
511+
expect(matchPrecacheMock).toHaveBeenCalledWith('index.html');
512+
await vi.runAllTimersAsync();
513+
});
514+
515+
it('case 3: network resolves within 8s returns network response without orphan timer', async () => {
516+
vi.useFakeTimers();
517+
518+
const networkHtml = '<html>network fresh</html>';
519+
const networkResponse = new Response(networkHtml, {
520+
status: 200,
521+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
522+
});
523+
524+
htmlCache.match.mockResolvedValue(undefined);
525+
matchPrecacheMock.mockImplementation((url: string) =>
526+
Promise.resolve(url === 'index.html' ? null : null),
527+
);
528+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(networkResponse.clone()));
529+
530+
const orphanRejections: unknown[] = [];
531+
const onRejection = (reason: unknown) => {
532+
orphanRejections.push(reason);
533+
};
534+
process.on('unhandledRejection', onRejection);
535+
536+
try {
537+
const handler = navigationHandlerRef.current!;
538+
const response = await handler({
539+
event: createNavigationEvent(),
540+
request: new Request(navigationUrl),
541+
});
542+
const body = await response.text();
543+
544+
expect(body).toBe(networkHtml);
545+
expect(matchPrecacheMock).toHaveBeenCalledWith('index.html');
546+
expect(matchPrecacheMock).not.toHaveBeenCalledWith('offline.html');
547+
548+
await vi.advanceTimersByTimeAsync(8000);
549+
expect(orphanRejections).toHaveLength(0);
550+
} finally {
551+
process.off('unhandledRejection', onRejection);
552+
}
553+
});
554+
555+
it('case 3: timeout fallback still waitUntils late network fetch to html-cache', async () => {
556+
vi.useFakeTimers();
557+
558+
const offlineFallback = createOfflineFallbackResponse();
559+
const networkHtml = '<html>late network</html>';
560+
let resolveFetch!: (value: Response) => void;
561+
const fetchPromise = new Promise<Response>((resolve) => {
562+
resolveFetch = resolve;
563+
});
564+
565+
htmlCache.match.mockResolvedValue(undefined);
566+
matchPrecacheMock.mockImplementation((url: string) => {
567+
if (url === 'index.html') return Promise.resolve(null);
568+
if (url === 'offline.html') return Promise.resolve(offlineFallback);
569+
return Promise.resolve(null);
570+
});
571+
vi.stubGlobal(
572+
'fetch',
573+
vi.fn(() => fetchPromise),
574+
);
575+
576+
const waitUntilMock = vi.fn();
577+
const event = { waitUntil: waitUntilMock } as unknown as ExtendableEvent;
578+
const handler = navigationHandlerRef.current!;
579+
const responsePromise = handler({
580+
event,
581+
request: new Request(navigationUrl),
582+
});
583+
584+
await vi.advanceTimersByTimeAsync(8000);
585+
586+
const response = await responsePromise;
587+
const body = await response.text();
588+
589+
expect(body).toBe(offlineHtml);
590+
expect(waitUntilMock).toHaveBeenCalledTimes(1);
591+
592+
resolveFetch(
593+
new Response(networkHtml, {
594+
status: 200,
595+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
596+
}),
597+
);
598+
const waitUntilPromise = waitUntilMock.mock.calls[0]?.[0] as Promise<unknown> | undefined;
599+
await waitUntilPromise;
600+
601+
expect(htmlCache.put).toHaveBeenCalled();
602+
});
603+
604+
it('case 3: hung network falls back to offline.html after bounded timeout', async () => {
605+
vi.useFakeTimers();
606+
607+
const offlineFallback = createOfflineFallbackResponse();
608+
609+
htmlCache.match.mockResolvedValue(undefined);
610+
matchPrecacheMock.mockImplementation((url: string) => {
611+
if (url === 'index.html') return Promise.resolve(null);
612+
if (url === 'offline.html') return Promise.resolve(offlineFallback);
613+
return Promise.resolve(null);
614+
});
615+
cachesMatch.mockResolvedValue(undefined);
616+
vi.stubGlobal(
617+
'fetch',
618+
vi.fn(() => new Promise<Response>(() => undefined)),
619+
);
620+
621+
const handler = navigationHandlerRef.current!;
622+
const responsePromise = handler({
623+
event: createNavigationEvent(),
624+
request: new Request(navigationUrl),
625+
});
626+
627+
await vi.advanceTimersByTimeAsync(8000);
628+
629+
const response = await responsePromise;
630+
const body = await response.text();
631+
632+
expect(body).toBe(offlineHtml);
633+
expect(matchPrecacheMock).toHaveBeenCalledWith('index.html');
634+
expect(matchPrecacheMock).toHaveBeenCalledWith('offline.html');
635+
expect(cachesOpen).toHaveBeenCalledWith(htmlCacheName);
636+
});
637+
});

apps/ratewise/src/sw.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
3131
// 保存 manifest 供 VERIFY_AND_REPAIR_PRECACHE 使用。
3232
const WB_MANIFEST = self.__WB_MANIFEST;
3333
const HTML_CACHE_NAME = 'html-cache';
34+
const NAVIGATION_FETCH_TIMEOUT_MS = 8000;
3435

3536
// 預快取 Vite 產出的靜態資源。
3637
precacheAndRoute(WB_MANIFEST);
@@ -312,11 +313,26 @@ async function handleNavigationRequest({
312313
return precachedShell;
313314
}
314315

315-
// precache 也 miss(例如 iOS eviction):等網路,真正失敗才用 offline fallback
316+
// precache 也 miss(iOS eviction):等網路但設上限,避免連線掛住造成無限白屏。
317+
const networkFetch = fetchAndCacheNavigation(request, cache);
318+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
316319
try {
317-
return await fetchAndCacheNavigation(request, cache);
320+
return await Promise.race([
321+
networkFetch,
322+
new Promise<never>((_, reject) => {
323+
timeoutId = setTimeout(
324+
() => reject(new Error('navigation-fetch-timeout')),
325+
NAVIGATION_FETCH_TIMEOUT_MS,
326+
);
327+
}),
328+
]);
318329
} catch {
330+
event.waitUntil(networkFetch.then(() => undefined).catch(() => undefined));
319331
return resolveNavigationFallback();
332+
} finally {
333+
if (timeoutId !== undefined) {
334+
clearTimeout(timeoutId);
335+
}
320336
}
321337
}
322338

0 commit comments

Comments
 (0)