diff --git a/.changeset/ratewise-precache-cold-start.md b/.changeset/ratewise-precache-cold-start.md
new file mode 100644
index 000000000..2d97d0e5f
--- /dev/null
+++ b/.changeset/ratewise-precache-cold-start.md
@@ -0,0 +1,9 @@
+---
+'@app/ratewise': patch
+---
+
+重整 PWA 快取為優先級分層,確保主功能(幣別換算)離線必可載入,並讓離線錯誤頁不再無謂出現:
+
+- Tier 1 預快取只保留 app shell(index.html)、JS/CSS、路由 loader 清單與少量 shell 圖示與離線頁,precache 由 428 筆(約 34.5MB)降至 92 筆(約 2.2MB),弱網下 Service Worker 能可靠安裝完成。
+- Tier 2 改為 runtime 快取:大型圖片(CacheFirst)、SEO 頁(導覽回退至 app shell)、匯率資料(StaleWhileRevalidate 7 天離線備援)。
+- 離線時一律以 app shell 還原主功能,offline.html 僅作最後手段,不再因預快取過大而誤觸。
diff --git a/.changeset/ratewise-precache-guardrails.md b/.changeset/ratewise-precache-guardrails.md
new file mode 100644
index 000000000..fc3d4eea7
--- /dev/null
+++ b/.changeset/ratewise-precache-guardrails.md
@@ -0,0 +1,5 @@
+---
+'@app/ratewise': patch
+---
+
+強化 PWA 快取分層的離線韌性與防回歸護欄:iOS 快取被驅逐後一併修復離線導覽所需的路由 loader 清單、擴充匯率離線備援的快取容量,並收緊預快取守門避免非必要資源回流,確保主功能離線載入更穩定。
diff --git a/apps/ratewise/public/offline.html b/apps/ratewise/public/offline.html
index 5d4d808b9..1594b5972 100644
--- a/apps/ratewise/public/offline.html
+++ b/apps/ratewise/public/offline.html
@@ -2,184 +2,13 @@
-
-
-
-
-
+
+
離線模式 - HaoRate
-
diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts
index 616faad01..6197780e2 100644
--- a/apps/ratewise/src/__tests__/sw.test.ts
+++ b/apps/ratewise/src/__tests__/sw.test.ts
@@ -191,7 +191,7 @@ describe('Service Worker Cache Strategies', () => {
const expectedStrategies = {
'history-rates-cdn': { strategy: 'CacheFirst', maxAge: 365 * 24 * 60 * 60 },
'latest-rate-cache': { strategy: 'StaleWhileRevalidate', maxAge: 7 * 24 * 60 * 60 },
- 'image-cache': { strategy: 'CacheFirst', maxAge: 90 * 24 * 60 * 60 },
+ 'image-cache': { strategy: 'CacheFirst', maxAge: 30 * 24 * 60 * 60, maxEntries: 60 },
'font-cache': { strategy: 'CacheFirst', maxAge: 365 * 24 * 60 * 60 },
'static-resources': { strategy: 'CacheFirst', maxAge: 30 * 24 * 60 * 60 },
};
@@ -231,6 +231,48 @@ describe('Service Worker Cache Strategies', () => {
expect(config.maxAge).toBe(7 * 24 * 60 * 60); // 7 days
});
+ it('should cache same-origin api latest and pairs JSON with StaleWhileRevalidate', async () => {
+ const fs = await import('node:fs/promises');
+ const path = await import('node:path');
+
+ const swPath = path.resolve(__dirname, '../sw.ts');
+ const sourceCode = await fs.readFile(swPath, 'utf-8');
+
+ expect(sourceCode).toContain("url.pathname.endsWith('/api/latest.json')");
+ expect(sourceCode).toContain("url.pathname.includes('/api/pairs/')");
+ // 防回歸:同域 API SWR 必須限制同源,避免 cross-origin pathname 碰撞污染 latest-rate-cache。
+ expect(sourceCode).toContain('url.origin === self.location.origin');
+ // 防回歸:共用 latest-rate-cache 需保留擴充幣對餘裕(GitHub raw + latest + 17 pairs)。
+ expect(sourceCode).toContain('maxEntries: 32');
+ });
+
+ it('should repair the loader-data manifest alongside JS/CSS after iOS eviction', async () => {
+ const fs = await import('node:fs/promises');
+ const path = await import('node:path');
+
+ const swPath = path.resolve(__dirname, '../sw.ts');
+ const sourceCode = await fs.readFile(swPath, 'utf-8');
+
+ // 防回歸:verifyAndRepairPrecache 必須涵蓋 static-loader-data-manifest(無 runtime route 後備)。
+ expect(sourceCode).toContain("relUrl.includes('static-loader-data-manifest')");
+ });
+
+ it('should use CacheFirst with 30-day expiration for runtime images', async () => {
+ const fs = await import('node:fs/promises');
+ const path = await import('node:path');
+
+ const swPath = path.resolve(__dirname, '../sw.ts');
+ const sourceCode = await fs.readFile(swPath, 'utf-8');
+ const config = expectedStrategies['image-cache'];
+
+ expect(sourceCode).toContain('IMAGE_EXTENSION_PATTERN');
+ expect(sourceCode).toContain("cacheName: 'image-cache'");
+ expect(sourceCode).toContain('maxEntries: 60');
+ expect(config.strategy).toBe('CacheFirst');
+ expect(config.maxAge).toBe(30 * 24 * 60 * 60);
+ expect(config.maxEntries).toBe(60);
+ });
+
it('should register a network-only route for connectivity probe', async () => {
const fs = await import('node:fs/promises');
const path = await import('node:path');
diff --git a/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts b/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts
index 051dfe8ee..1f2d0d27e 100644
--- a/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts
+++ b/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts
@@ -59,4 +59,49 @@ describe('verify-precache-assets script', () => {
path.resolve('/repo/apps/ratewise/dist', 'assets/app.css'),
);
});
+
+ it('should define tier-1 precache guardrails for shell assets and forbidden runtime-only resources', async () => {
+ const script = await loadVerifyPrecacheModule();
+
+ expect(script.MAX_PRECACHE_ENTRY_COUNT).toBe(100);
+ expect(script.MAX_PRECACHE_BYTES).toBe(3 * 1024 * 1024);
+ expect(script.REQUIRED_PRECACHE_URLS).toEqual(
+ expect.arrayContaining([
+ 'index.html',
+ 'offline.html',
+ 'favicon.svg',
+ 'favicon.ico',
+ 'apple-touch-icon.png',
+ 'icons/ratewise-icon-192x192.png',
+ ]),
+ );
+ expect(
+ script.FORBIDDEN_PRECACHE_PATTERNS.some((pattern: RegExp) =>
+ pattern.test('screenshots/a.png'),
+ ),
+ ).toBe(true);
+ expect(
+ script.FORBIDDEN_PRECACHE_PATTERNS.some((pattern: RegExp) =>
+ pattern.test('usd-twd/index.html'),
+ ),
+ ).toBe(true);
+ });
+
+ it('requires the hash-named loader-data manifest in precache (offline SPA nav)', async () => {
+ const script = await loadVerifyPrecacheModule();
+ expect(script.REQUIRED_PRECACHE_SUBSTRINGS).toContain('static-loader-data-manifest');
+ });
+
+ it('forbids Tier 2 runtime assets (api JSON, nested index.html, raster images) in precache', async () => {
+ const script = await loadVerifyPrecacheModule();
+ const isForbidden = (url: string): boolean =>
+ script.FORBIDDEN_PRECACHE_PATTERNS.some((pattern: RegExp) => pattern.test(url));
+ expect(isForbidden('api/latest.json')).toBe(true);
+ expect(isForbidden('api/pairs/usd-twd.json')).toBe(true);
+ expect(isForbidden('about/index.html')).toBe(true);
+ expect(isForbidden('faq/index.html')).toBe(true);
+ expect(isForbidden('og-image.jpg')).toBe(true);
+ // 根 index.html 與 shell 圖示不得被視為禁止項。
+ expect(isForbidden('index.html')).toBe(false);
+ });
});
diff --git a/apps/ratewise/src/pwa-offline.test.ts b/apps/ratewise/src/pwa-offline.test.ts
index de5b69d2d..1949c6cd2 100644
--- a/apps/ratewise/src/pwa-offline.test.ts
+++ b/apps/ratewise/src/pwa-offline.test.ts
@@ -292,6 +292,27 @@ describe('PWA 離線功能測試', () => {
expect(viteConfig).not.toContain('**/*.{js,css,html');
});
+ it('should keep images out of precache globPatterns and add shell icons via additionalManifestEntries', () => {
+ const viteConfig = readFileSync(resolve(ROOT_PATH, 'vite.config.ts'), 'utf-8');
+ expect(viteConfig).not.toContain("'**/*.png'");
+ expect(viteConfig).not.toContain("'**/*.ico'");
+ expect(viteConfig).not.toContain("'**/*.svg'");
+ expect(viteConfig).not.toContain("'**/*.webp'");
+ expect(viteConfig).not.toContain("'**/*.avif'");
+ expect(viteConfig).not.toContain("'**/*.json'");
+ expect(viteConfig).toContain("'favicon.svg'");
+ expect(viteConfig).toContain("'favicon.ico'");
+ expect(viteConfig).toContain("'apple-touch-icon.png'");
+ expect(viteConfig).toContain("'icons/ratewise-icon-192x192.png'");
+ });
+
+ it('should exclude nested HTML and openapi from precache via globIgnores', () => {
+ const viteConfig = readFileSync(resolve(ROOT_PATH, 'vite.config.ts'), 'utf-8');
+ expect(viteConfig).toContain("'**/*/index.html'");
+ expect(viteConfig).toContain("'**/openapi.json'");
+ expect(viteConfig).toContain("'**/screenshots/**'");
+ });
+
it('should verify index.html and shell assets exist in the generated precache manifest', () => {
const verifyScript = readFileSync(
resolve(ROOT_PATH, '../../scripts/verify-precache-assets.mjs'),
diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts
index 4e1149f21..a8cb9be89 100644
--- a/apps/ratewise/src/sw.ts
+++ b/apps/ratewise/src/sw.ts
@@ -88,9 +88,15 @@ async function verifyAndRepairPrecache(): Promise {
const scope = self.registration.scope;
type ManifestEntry = string | { url: string; revision?: string | null };
+ // 補回 iOS eviction 清除的 Tier 1 shell 資產:JS/CSS 與路由 loader 清單。
+ // loader 清單為離線 SPA 子路由導覽必要,且無 runtime route 後備,故一併修復。
const missing = (WB_MANIFEST as ManifestEntry[]).filter((entry) => {
const relUrl = typeof entry === 'string' ? entry : entry.url;
- if (!relUrl.endsWith('.js') && !relUrl.endsWith('.css')) return false;
+ const isRepairable =
+ relUrl.endsWith('.js') ||
+ relUrl.endsWith('.css') ||
+ relUrl.includes('static-loader-data-manifest');
+ if (!isRepairable) return false;
const fullUrl = new URL(relUrl, scope).href;
return !cachedUrls.has(fullUrl);
});
@@ -394,33 +400,52 @@ registerRoute(
}),
);
-// 最新匯率:StaleWhileRevalidate,離線備援 7 天。
+const LATEST_RATE_SWR_PLUGINS = [
+ new CacheableResponsePlugin({ statuses: [0, 200] }),
+ new ExpirationPlugin({
+ // GitHub raw + 同域 api/latest + 17 個 api/pairs 共用此快取(約 19 筆),
+ // 預留擴充幣對的餘裕,避免 LRU 驅逐離線匯率備援。
+ maxEntries: 32,
+ maxAgeSeconds: 60 * 60 * 24 * 7, // 7 天
+ }),
+];
+
+// 最新匯率(GitHub raw):StaleWhileRevalidate,離線備援 7 天。
registerRoute(
({ url }: { url: URL }) =>
url.origin === 'https://raw.githubusercontent.com' &&
url.pathname.includes('/public/rates/latest.json'),
new StaleWhileRevalidate({
cacheName: 'latest-rate-cache',
- plugins: [
- new CacheableResponsePlugin({ statuses: [0, 200] }),
- new ExpirationPlugin({
- maxEntries: 1,
- maxAgeSeconds: 60 * 60 * 24 * 7, // 7 天
- }),
- ],
+ plugins: LATEST_RATE_SWR_PLUGINS,
}),
);
-// 圖片:CacheFirst,90 天。
+// 同域匯率 API(latest / pairs):StaleWhileRevalidate,離線備援 7 天。
registerRoute(
- ({ request }: { request: Request }) => request.destination === 'image',
+ ({ url }: { url: URL }) =>
+ url.origin === self.location.origin &&
+ (url.pathname.endsWith('/api/latest.json') ||
+ (url.pathname.includes('/api/pairs/') && url.pathname.endsWith('.json'))),
+ new StaleWhileRevalidate({
+ cacheName: 'latest-rate-cache',
+ plugins: LATEST_RATE_SWR_PLUGINS,
+ }),
+);
+
+const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|webp|avif)$/i;
+
+// 圖片(Tier 2):CacheFirst,按需快取大圖示 / OG / screenshots 等。
+registerRoute(
+ ({ request, url }: { request: Request; url: URL }) =>
+ request.destination === 'image' || IMAGE_EXTENSION_PATTERN.test(url.pathname),
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
- maxEntries: 150,
- maxAgeSeconds: 60 * 60 * 24 * 90, // 90 天
+ maxEntries: 60,
+ maxAgeSeconds: 60 * 60 * 24 * 30, // 30 天
}),
],
}),
diff --git a/apps/ratewise/vite.config.ts b/apps/ratewise/vite.config.ts
index 017ba7422..ca8d8c2f9 100644
--- a/apps/ratewise/vite.config.ts
+++ b/apps/ratewise/vite.config.ts
@@ -298,33 +298,43 @@ export default defineConfig(({ mode }) => {
injectRegister: 'inline',
injectManifest: {
- // 避免 brace expansion 相依異常導致 glob 全數失效,明確列出副檔名。
- // 含 json 以預快取 React Router data manifest(離線 SPA 導覽必要)。
+ // Tier 1 precache:app shell(index.html)+ JS/CSS。
+ // 圖片改 runtime CacheFirst、匯率 JSON 改 runtime SWR(見 sw.ts)。
+ // static-loader-data-manifest 為 vite-react-ssg 路由 loader 資料清單,
+ // 屬 app shell 一環、離線 SPA 導覽必要,必須保留 precache(內容雜湊命名)。
globPatterns: [
'**/*.js',
'**/*.css',
'**/*.html',
- '**/*.ico',
- '**/*.png',
- '**/*.svg',
- '**/*.avif',
- '**/*.webp',
- '**/*.json',
+ '**/static-loader-data-manifest-*.json',
],
globIgnores: [
'**/og-image-old.png',
'**/node_modules/**',
'**/lighthouse-reports/**',
- '**/rates/**/*.json',
'**/pwa-install/**',
+ '**/screenshots/**',
'**/offline.html',
'**/sitemap.xml',
'**/robots.txt',
'**/llms.txt',
'**/manifest.webmanifest',
+ '**/openapi.json',
+ // 非 shell HTML(幣別 landing、金額子頁、about/faq 等)由 NavigationRoute → index.html 處理。
+ '**/*/index.html',
],
additionalManifestEntries: [
{ url: 'offline.html', revision: getFileRevision('public/offline.html') },
+ { url: 'favicon.svg', revision: getFileRevision('public/favicon.svg') },
+ { url: 'favicon.ico', revision: getFileRevision('public/favicon.ico') },
+ {
+ url: 'apple-touch-icon.png',
+ revision: getFileRevision('public/apple-touch-icon.png'),
+ },
+ {
+ url: 'icons/ratewise-icon-192x192.png',
+ revision: getFileRevision('public/icons/ratewise-icon-192x192.png'),
+ },
],
rollupFormat: 'iife',
// SW 中 location 全域變數 polyfill(Workbox 相容性)
diff --git a/scripts/verify-precache-assets.mjs b/scripts/verify-precache-assets.mjs
index e346d781c..c88d2bfa5 100755
--- a/scripts/verify-precache-assets.mjs
+++ b/scripts/verify-precache-assets.mjs
@@ -2,7 +2,7 @@
/* eslint-env node */
/* eslint-disable no-undef */
-import { readFile, existsSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
import { readFile as readFileAsync } from 'node:fs/promises';
import path from 'node:path';
@@ -13,6 +13,34 @@ const DIST_DIR = path.resolve(PROJECT_ROOT, 'apps/ratewise/dist');
const SW_PATH = path.resolve(PROJECT_ROOT, 'apps/ratewise/dist/sw.js');
const INDEX_HTML_PATH = path.resolve(DIST_DIR, 'index.html');
const MIN_PRECACHE_ENTRY_COUNT = 20;
+const MAX_PRECACHE_ENTRY_COUNT = 100;
+const MAX_PRECACHE_BYTES = 3 * 1024 * 1024;
+
+const REQUIRED_PRECACHE_URLS = [
+ 'index.html',
+ 'offline.html',
+ 'favicon.svg',
+ 'favicon.ico',
+ 'apple-touch-icon.png',
+ 'icons/ratewise-icon-192x192.png',
+];
+
+// Tier 1 必含但檔名帶 hash 的資產,以子字串比對。
+const REQUIRED_PRECACHE_SUBSTRINGS = ['static-loader-data-manifest'];
+
+const FORBIDDEN_PRECACHE_PATTERNS = [
+ /screenshots\//,
+ /pwa-install\//,
+ /-1024x1024\.png/,
+ /pwa-512x512\.png/,
+ /openapi\.json$/,
+ // 匯率 JSON 屬 Tier 2 runtime SWR,不得進 precache(loader manifest 例外,於 REQUIRED 檢查)。
+ /(?:^|\/)api\/(?:latest\.json|pairs\/)/,
+ // 任何非根目錄 index.html(幣別 landing、about、faq 等 SSG 頁)由 NavigationRoute 回退 shell。
+ /.+\/index\.html$/,
+ // 點陣圖一律 runtime CacheFirst(REQUIRED 的 shell 圖示於下方掃描時排除)。
+ /\.(?:png|jpe?g|webp|avif)$/,
+];
function normalizeBase(url) {
// Remove trailing slashes first, then add a single slash
@@ -213,6 +241,61 @@ async function main() {
);
}
+ if (entries.length > MAX_PRECACHE_ENTRY_COUNT) {
+ throw new Error(
+ `precache 條目過多:目前 ${entries.length} 筆,上限 ${MAX_PRECACHE_ENTRY_COUNT} 筆。請確認 globIgnores 已排除非 Tier 1 資源。`,
+ );
+ }
+
+ const missingRequired = REQUIRED_PRECACHE_URLS.filter((url) => !entryUrls.has(url));
+ if (missingRequired.length > 0) {
+ throw new Error(`precache 缺少 Tier 1 shell 資產:${missingRequired.join(', ')}。`);
+ }
+
+ const missingRequiredSubstrings = REQUIRED_PRECACHE_SUBSTRINGS.filter(
+ (needle) => ![...entryUrls].some((url) => url.includes(needle)),
+ );
+ if (missingRequiredSubstrings.length > 0) {
+ throw new Error(
+ `precache 缺少 Tier 1 雜湊命名資產:${missingRequiredSubstrings.join(', ')}(離線 SPA 導覽必要)。`,
+ );
+ }
+
+ const requiredUrlSet = new Set(REQUIRED_PRECACHE_URLS);
+ const forbiddenMatches = entries
+ .map((entry) => entry.url)
+ .filter(
+ (url) =>
+ url &&
+ !requiredUrlSet.has(url) &&
+ FORBIDDEN_PRECACHE_PATTERNS.some((pattern) => pattern.test(url)),
+ );
+ if (forbiddenMatches.length > 0) {
+ throw new Error(
+ `precache 洩漏非 Tier 1 資源:${forbiddenMatches.slice(0, 5).join(', ')}${
+ forbiddenMatches.length > 5 ? '…' : ''
+ }`,
+ );
+ }
+
+ if (VERIFY_SOURCE === 'local') {
+ let totalBytes = 0;
+ for (const entry of entries) {
+ const localPath = resolveLocalPrecacheAssetPath(entry.url, DIST_DIR);
+ if (existsSync(localPath)) {
+ totalBytes += readFileSync(localPath).byteLength;
+ }
+ }
+ if (totalBytes > MAX_PRECACHE_BYTES) {
+ throw new Error(
+ `precache 總體積過大:${(totalBytes / 1024 / 1024).toFixed(2)}MB,上限 ${MAX_PRECACHE_BYTES / 1024 / 1024}MB。`,
+ );
+ }
+ console.log(
+ `📦 precache 體積:${entries.length} 筆 / ${(totalBytes / 1024 / 1024).toFixed(2)}MB`,
+ );
+ }
+
if (!entryUrls.has('index.html')) {
throw new Error('precache 缺少 index.html,冷啟動離線導覽將直接失敗。');
}
@@ -287,4 +370,9 @@ export {
parseShellAssetUrls,
shouldProbePrecacheAssetsOverHttp,
resolveLocalPrecacheAssetPath,
+ REQUIRED_PRECACHE_URLS,
+ REQUIRED_PRECACHE_SUBSTRINGS,
+ FORBIDDEN_PRECACHE_PATTERNS,
+ MAX_PRECACHE_ENTRY_COUNT,
+ MAX_PRECACHE_BYTES,
};