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-bounded-nav.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': patch
---

離線/弱網下載入更穩定,不再可能卡白屏。
Comment thread
s123104 marked this conversation as resolved.
167 changes: 165 additions & 2 deletions apps/ratewise/src/__tests__/sw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>)
| 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<Response>,
) {
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/';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 = '<html>offline fallback</html>';

let htmlCache: {
match: ReturnType<typeof vi.fn>;
put: ReturnType<typeof vi.fn>;
};
let cachesOpen: ReturnType<typeof vi.fn>;
let cachesMatch: ReturnType<typeof vi.fn>;

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('<html>precached index</html>', {
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<Response>(() => 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<Response>(() => 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);
});
});
17 changes: 15 additions & 2 deletions apps/ratewise/src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<never>((_, 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();
}
}
Expand Down
Loading