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
7 changes: 7 additions & 0 deletions .changeset/ratewise-sw-bounded-nav.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@app/ratewise': patch
---
Comment thread
s123104 marked this conversation as resolved.

離線/弱網下導覽不再被卡住的網路請求拖住,避免長時間白屏。

- 網路成功時清除 8 秒 race timer,避免 orphan rejection 與 timer 洩漏。
258 changes: 256 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 @@ -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', () => {
Expand Down Expand Up @@ -381,3 +431,207 @@ 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: network resolves within 8s returns network response without orphan timer', async () => {
vi.useFakeTimers();

const networkHtml = '<html>network fresh</html>';
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 = '<html>late network</html>';
let resolveFetch!: (value: Response) => void;
const fetchPromise = new Promise<Response>((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 waitUntilMock = vi.fn();
const event = { waitUntil: waitUntilMock } as unknown as ExtendableEvent;
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(waitUntilMock).toHaveBeenCalledTimes(1);

resolveFetch(
new Response(networkHtml, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}),
);
const waitUntilPromise = waitUntilMock.mock.calls[0]?.[0] as Promise<unknown> | undefined;
await waitUntilPromise;

expect(htmlCache.put).toHaveBeenCalled();
});

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);
});
});
20 changes: 18 additions & 2 deletions apps/ratewise/src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -312,11 +313,26 @@ async function handleNavigationRequest({
return precachedShell;
}

// precache 也 miss(例如 iOS eviction):等網路,真正失敗才用 offline fallback
// precache 也 miss(iOS eviction):等網路但設上限,避免連線掛住造成無限白屏。
const networkFetch = fetchAndCacheNavigation(request, cache);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await fetchAndCacheNavigation(request, cache);
return await Promise.race([
networkFetch,
new Promise<never>((_, 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);
}
}
}

Expand Down
Loading