Skip to content

Commit 1e7517f

Browse files
toomoreclaude
andauthored
fix(pwa): 離線時沒快取的頁面掉到瀏覽器錯誤畫面,不是站台的離線頁 (#270)
「清除所有離線內容」把 Cache Storage 整個清光,離線提示頁本身也在裡面。清完之後 自動預快取是關的,所以那一頁不會被補回來,於是任何沒快取過的網址在離線時都會一路 走到 networkFirst 最後的 throw,respondWith 跟著 reject,讀者看到的是瀏覽器自己的 網路錯誤畫面。 同一個狀況也發生在「讀者自己把自動存下核心章節關掉」的情況,那時整個站在離線時 都沒有說明頁可看。 改法是把「沒有網路時至少要有的那一小批」拉出來,不受那個開關管:離線提示頁本身, 加上撐得起它的 app shell(含頁面索引與管理頁的程式)。共 10 個 URL、0.51 MB, 相對於完整章節的十 MB 是可以接受的底線。 留這一批的理由不只是體面。那一頁就是離線內容管理頁,讀者想清掉裝置上的東西、或想 知道自己還有哪些內容可讀,往往正好是連不上網的時候,那一頁進不去的話整個功能等於 不存在。 precacheFor 現在自己判斷開關要抓多少,install 與 PRECACHE_LANG 兩個呼叫端不再各自 檢查。三語系的管理頁文案與 offline.md 都說明了這一批的存在與大小。 check_precache.mjs 多報一行,數字有工具可查。 測試從 26 項加到 32 項。原本 networkFirst 的 fallback 鏈一項都沒測到,這次補上四項 (離線落到離線頁、有快取就不落、各語系落到自己那份、連離線頁都沒有才丟錯誤), 清除相關的兩項改成驗證底線會補回而章節不會。反向驗證確認拿掉離線提示頁會讓兩項變紅。 端到端另外用 headless Chrome 走過一遍:清除、重新上站(precache 補回 10 筆)、 關掉本機 server、導覽到從未造訪的網址,拿到的是「離線閱讀」頁。 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent afce2e7 commit 1e7517f

7 files changed

Lines changed: 119 additions & 29 deletions

File tree

docs/en/offline.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ Scenario pages for journalists, activists, LGBTQ+ readers and survivors of domes
2626

2727
The reading language you pick from the language menu is stored in the same place. The site uses it to decide which language version to open when you arrive at the home page next time, and for nothing else. The language your browser reports is never read: on Tor Browser that value is always English, so acting on it would send readers in Taiwan to the English version.
2828

29-
"Clear all offline content" above removes both what the site stored and what you picked, and stops the site from storing anything new. Turn the switch back on above to resume automatic storage.
29+
"Clear all offline content" above removes both what the site stored and what you picked, and stops the site from storing new chapters. Turn the switch back on above to resume.
30+
31+
This page itself and the styles it needs are outside that switch, and come back the next time you are online (about 0.5 MB). They stay because the moment you want to clear what is on the device, or check what is still readable, is often the moment you have no connection, and without this page all that is left is the browser error screen.
3032

3133
What that button does not cover: browsing history, DNS cache, and files you downloaded from the site. Handle those in your browser or system settings. In Chrome this is Clear browsing data. In Firefox it is Clear recent history with "Cookies and site data" ticked under the details. Selecting only "Cached images and files" does not remove site storage.
3234

docs/zh-CN/offline.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ hide:
2626

2727
你在语言菜单选过的阅读语言也存在同一个地方,站台用它决定下次从首页进来时打开哪一个语言版本,除此之外不会拿去做别的事。站台不会读取浏览器报告的语言,那个值在 Tor Browser 上一律是英文,照着判断会把台湾读者带去英文版。
2828

29-
上面的「清除所有离线内容」会把站台存的与你自己选的一起清掉,并停止自动存新的东西。要恢复自动存,回到上面把开关打开即可。
29+
上面的「清除所有离线内容」会把站台存的与你自己选的一起清掉,并停止自动存新的章节。要恢复,回到上面把开关打开即可。
30+
31+
这一页本身与它需要的样式不受那个开关管,下次连上网时会补回来(约 0.5 MB)。留着它是因为想清掉设备上的东西、或想看还有哪些内容可读的时候,往往正好是连不上网的时候,那时这一页进不去的话就只剩浏览器的错误画面。
3032

3133
那颗按钮清不掉的东西:浏览记录、DNS 缓存,以及你从站上下载过的文件。那些要在浏览器或系统设置里处理。Chrome 是设置的「清除浏览数据」,Firefox 是「清除最近的历史记录」并展开细项勾选「Cookie 和网站数据」。只勾「缓存的图片和文件」清不掉网站存储这一类。
3234

docs/zh-TW/js/offline-library.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,13 @@
110110
usage: "本站在這台裝置上佔用 {used}",
111111
usageFree: "本站在這台裝置上佔用 {used},可用空間還有 {free}",
112112
autoLabel: "自動存下目前語言的核心章節",
113-
autoHint: "關掉之後,站台不會再自動存任何東西,只保留你自己勾選的。",
113+
autoHint: "關掉之後就不再自動存章節。這一頁本身與它需要的樣式仍會留著(約 0.5 MB),沒有網路時你才進得來。你自己勾的頁面不受影響。",
114114
refresh: "更新已存的內容",
115115
refreshing: "更新中",
116116
clear: "清除所有離線內容",
117117
clearAgain: "再按一次就清除",
118118
clearing: "清除中",
119-
cleared: "已清除。瀏覽記錄、DNS 快取與你下載過的檔案不在清除範圍內,那些要在瀏覽器或系統裡處理。",
119+
cleared: "已清除,自動存下章節也一併關掉了。下次連上網時會補回這一頁本身與它需要的樣式(約 0.5 MB),讓你在沒有網路時仍進得來。瀏覽記錄、DNS 快取與你下載過的檔案不在清除範圍內,那些要在瀏覽器或系統裡處理。",
120120
apply: "套用變更",
121121
applying: "處理中",
122122
pending: "待新增 {add} 頁,待移除 {remove} 頁",
@@ -139,13 +139,13 @@
139139
usage: "本站在这台设备上占用 {used}",
140140
usageFree: "本站在这台设备上占用 {used},可用空间还有 {free}",
141141
autoLabel: "自动存下当前语言的核心章节",
142-
autoHint: "关掉之后,站台不会再自动存任何东西,只保留你自己勾选的。",
142+
autoHint: "关掉之后就不再自动存章节。这一页本身与它需要的样式仍会留着(约 0.5 MB),没有网络时你才进得来。你自己勾的页面不受影响。",
143143
refresh: "更新已存的内容",
144144
refreshing: "更新中",
145145
clear: "清除所有离线内容",
146146
clearAgain: "再按一次就清除",
147147
clearing: "清除中",
148-
cleared: "已清除。浏览记录、DNS 缓存与你下载过的文件不在清除范围内,那些要在浏览器或系统里处理。",
148+
cleared: "已清除,自动存下章节也一并关掉了。下次连上网时会补回这一页本身与它需要的样式(约 0.5 MB),让你在没有网络时仍进得来。浏览记录、DNS 缓存与你下载过的文件不在清除范围内,那些要在浏览器或系统里处理。",
149149
apply: "应用变更",
150150
applying: "处理中",
151151
pending: "待新增 {add} 页,待移除 {remove} 页",
@@ -168,13 +168,13 @@
168168
usage: "This site uses {used} on this device",
169169
usageFree: "This site uses {used} on this device, with {free} still available",
170170
autoLabel: "Automatically store the core chapters for the current language",
171-
autoHint: "Turn this off and the site stores nothing on its own. Only what you tick stays.",
171+
autoHint: "Turning this off stops the site from storing chapters. This page itself and the styles it needs stay (about 0.5 MB), so you can still get here without a network. Pages you ticked are unaffected.",
172172
refresh: "Update what is stored",
173173
refreshing: "Updating",
174174
clear: "Clear all offline content",
175175
clearAgain: "Press again to clear",
176176
clearing: "Clearing",
177-
cleared: "Cleared. Browsing history, DNS cache and files you downloaded are not covered here. Handle those in your browser or system settings.",
177+
cleared: "Cleared, and automatic storage of chapters is off. Next time you are online, this page itself and the styles it needs come back (about 0.5 MB) so you can still reach it without a network. Browsing history, DNS cache and files you downloaded are not covered here. Handle those in your browser or system settings.",
178178
apply: "Apply changes",
179179
applying: "Working",
180180
pending: "{add} to add, {remove} to remove",

docs/zh-TW/offline.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ hide:
2626

2727
你在語言選單選過的閱讀語言也存在同一個地方,站台用它決定下次從首頁進來時開哪一個語言版本,除此之外不會拿去做別的事。站台不會讀取瀏覽器回報的語言,那個值在 Tor Browser 上一律是英文,照著判斷會把台灣讀者帶去英文版。
2828

29-
上面的「清除所有離線內容」會把站台存的與你自己選的一起清掉,並停止自動存新的東西。要恢復自動存,回到上面把開關打開即可。
29+
上面的「清除所有離線內容」會把站台存的與你自己選的一起清掉,並停止自動存新的章節。要恢復,回到上面把開關打開即可。
30+
31+
這一頁本身與它需要的樣式不受那個開關管,下次連上網時會補回來(約 0.5 MB)。留著它是因為想清掉裝置上的東西、或想看還有哪些內容可讀的時候,往往正好是連不上網的時候,那時這一頁進不去的話就只剩瀏覽器的錯誤畫面。
3032

3133
那顆按鈕清不掉的東西:瀏覽記錄、DNS 快取,以及你從站上下載過的檔案。那些要在瀏覽器或系統設定裡處理。Chrome 是設定的「清除瀏覽資料」,Firefox 是「清除近期歷史」並展開細項勾選「Cookie 和網站資料」。只勾「快取的圖片和檔案」清不掉網站儲存這一類。
3234

docs/zh-TW/sw.js

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,22 @@ function precacheUrlsFor(prefix) {
328328
return urls;
329329
}
330330

331+
// 沒有網路時至少要有的那一小批:離線提示頁本身,加上撐得起它的 app shell。
332+
//
333+
// 為什麼不受「自動存下核心章節」的開關管:那一頁就是離線內容管理頁。讀者想清掉
334+
// 裝置上的東西、或想知道自己還有哪些內容可讀,往往正好是連不上網的時候,那一頁
335+
// 進不去的話整個功能等於不存在。少了它,沒快取過的網址在離線時會一路走到
336+
// networkFirst 最後的 throw,讀者看到的是瀏覽器自己的網路錯誤畫面。
337+
//
338+
// 這一批約 0.5 MB,相對於完整章節的十 MB 是可以接受的底線。
339+
function essentialUrlsFor(prefix) {
340+
const urls = [SCOPE_PATH + prefix + "offline/"];
341+
for (const asset of SHELL_ASSETS) {
342+
urls.push(SCOPE_PATH + prefix + asset);
343+
}
344+
return urls;
345+
}
346+
331347
// 這個 SW 生命週期內已經補過的語系。每次導覽 client 都會送 PRECACHE_LANG 過來,
332348
// 沒有這層就要對七十幾個網址各做一次 cache.match 才知道沒事可做。SW 被瀏覽器終止
333349
// 後清空,下次重跑一輪也只是白查一次,不會抓重複的東西。
@@ -336,12 +352,15 @@ const precachedPrefixes = new Set();
336352
// 補齊某個語系的預快取。已經在快取裡的跳過,所以換語系時只會抓新的那一份,作品
337353
// 本體與抓過的東西不重來。
338354
async function precacheFor(prefix) {
339-
if (precachedPrefixes.has(prefix)) return;
340-
precachedPrefixes.add(prefix);
355+
// 讀者關掉自動存或清空過內容時只補底線那一批,不抓完整章節
356+
const full = await autoPrecacheEnabled();
357+
const done = prefix + (full ? " full" : " essential");
358+
if (precachedPrefixes.has(done)) return;
359+
precachedPrefixes.add(done);
341360
const cache = await caches.open(PRECACHE);
342361
// 逐一快取並容忍個別失敗(本地開發只有單一語系,其他語系路徑會 404)
343362
await Promise.allSettled(
344-
precacheUrlsFor(prefix).map(async (url) => {
363+
(full ? precacheUrlsFor(prefix) : essentialUrlsFor(prefix)).map(async (url) => {
345364
if (await cache.match(url)) return;
346365
const response = await fetch(url, { credentials: "same-origin" });
347366
if (response.ok) await cache.put(url, response);
@@ -386,7 +405,6 @@ async function guessLangPrefix() {
386405
self.addEventListener("install", (event) => {
387406
event.waitUntil(
388407
(async () => {
389-
if (!(await autoPrecacheEnabled())) return;
390408
const prefix = await guessLangPrefix();
391409
if (prefix !== null) await precacheFor(prefix);
392410
// 這裡刻意不呼叫 skipWaiting。原本一裝好就搶著接管,讀者正在讀的分頁會在毫無
@@ -537,11 +555,7 @@ self.addEventListener("message", (event) => {
537555
if (data.type === "PRECACHE_LANG" && typeof data.url === "string") {
538556
const prefix = langPrefixOf(new URL(data.url));
539557
if (prefix !== null) {
540-
event.waitUntil(
541-
(async () => {
542-
if (await autoPrecacheEnabled()) await precacheFor(prefix);
543-
})()
544-
);
558+
event.waitUntil(precacheFor(prefix));
545559
}
546560
return;
547561
}

tools/check_precache.mjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ const harness = `
5757
${grab(/^const GAME_APPS = \[[\s\S]*?\n\];/m)}
5858
${grab(/^const CORE_PAGES_BY_PREFIX = \{[\s\S]*?\n\};/m)}
5959
${grab(/^function precacheUrlsFor\(prefix\) \{[\s\S]*?\n\}/m)}
60+
${grab(/^function essentialUrlsFor\(prefix\) \{[\s\S]*?\n\}/m)}
6061
${grab(/^function cacheKeyCandidates\(pathname\) \{[\s\S]*?\n\}/m)}
6162
// 執行期一次只預快取一個語系,檢查要涵蓋全部,所以逐一跑過再合併
6263
const byPrefix = LANG_PREFIXES.map((prefix) => [prefix, precacheUrlsFor(prefix)]);
63-
return { byPrefix, games: GAME_APPS.length, cacheKeyCandidates };
64+
const essential = essentialUrlsFor("");
65+
return { byPrefix, essential, games: GAME_APPS.length, cacheKeyCandidates };
6466
`;
65-
const { byPrefix, games, cacheKeyCandidates } = new Function(harness)();
67+
const { byPrefix, essential, games, cacheKeyCandidates } = new Function(harness)();
6668
// 作品本體每個語系的清單裡都有,合併時去重
6769
const urls = [...new Set(byPrefix.flatMap(([, list]) => list))];
6870

@@ -98,6 +100,9 @@ for (const [prefix, list] of byPrefix) {
98100
// 作品本體單獨報一次,那是最容易漏補的一批
99101
const gameUrls = urls.filter((u) => u.startsWith('/docs/games/') && !u.endsWith('/games/'));
100102
console.log(` 其中三件互動作品 ${mb(sizeOf(gameUrls))}${games} 個檔案,三語共用)`);
103+
// 讀者關掉自動存或清空過內容時只會有這一批。少了它離線就沒有站台的說明頁可看,
104+
// 落到瀏覽器自己的錯誤畫面,所以它不受那個開關管。
105+
console.log(` 關掉自動存時仍保留的底線 ${mb(sizeOf(essential))}${essential.length} 個 URL)`);
101106
console.log(` 全部語系去重後 ${mb(bytes)},這是底下兩道檢查涵蓋的範圍`);
102107

103108
// 索引頁連出去的網址形狀,要能命中預快取的 key

tools/test_sw_offline.mjs

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const harness = `
108108
${grab(/^const GAME_APPS = \[[\s\S]*?\n\];/m)}
109109
${grab(/^const CORE_PAGES_BY_PREFIX = \{[\s\S]*?\n\};/m)}
110110
${grab(/^function precacheUrlsFor\(prefix\) \{[\s\S]*?\n\}/m)}
111+
${grab(/^function essentialUrlsFor\(prefix\) \{[\s\S]*?\n\}/m)}
111112
${grab(/^const precachedPrefixes = .*$/m)}
112113
${grab(/^async function precacheFor\(prefix\) \{[\s\S]*?\n\}/m)}
113114
${grab(/^function langPrefixOf\(url\) \{[\s\S]*?\n\}/m)}
@@ -131,12 +132,14 @@ const harness = `
131132
${grab(/^function offlinePathFor\(url\) \{[\s\S]*?\n\}/m)}
132133
${grab(/^async function trimCache\(cacheName, maxEntries\) \{[\s\S]*?\n\}/m)}
133134
${grab(/^async function migrateLegacyRuntime\(\) \{[\s\S]*?\n\}/m)}
135+
${grab(/^function keepAlive\(event, promise\) \{[\s\S]*?\n\}/m)}
136+
${grab(/^async function networkFirst\(request, event\) \{[\s\S]*?\n\}/m)}
134137
return {
135138
RUNTIME_PAGES, RUNTIME_ASSETS, PAGES_MAX_ENTRIES, PRECACHE, LIBRARY, SETTINGS,
136139
cacheKeyCandidates, matchCachedPage, offlinePathFor, migrateLegacyRuntime,
137-
langPrefixOf, precacheUrlsFor, precacheFor, guessLangPrefix,
140+
langPrefixOf, precacheUrlsFor, essentialUrlsFor, precacheFor, guessLangPrefix,
138141
autoPrecacheEnabled, setAutoPrecache, libraryEntries, addToLibrary,
139-
removeFromLibrary, clearAllOffline, handleLibraryMessage,
142+
removeFromLibrary, clearAllOffline, handleLibraryMessage, networkFirst,
140143
};
141144
`;
142145

@@ -149,8 +152,10 @@ const harness = `
149152
const load = (opts = {}) => {
150153
const caches = new FakeCacheStorage();
151154
const fetched = [];
155+
const net = { offline: !!opts.offline };
152156
const fetchStub = async (url) => {
153157
fetched.push(url);
158+
if (net.offline) throw new TypeError("Failed to fetch");
154159
return { ok: !(opts.notFound || []).includes(url), url };
155160
};
156161
const selfStub = {
@@ -162,7 +167,7 @@ const load = (opts = {}) => {
162167
const sw = new Function('caches', 'SCOPE_PATH', 'fetch', 'self', 'navigator', harness)(
163168
caches, SCOPE_PATH, fetchStub, selfStub, navigatorStub
164169
);
165-
return { sw, caches, fetched };
170+
return { sw, caches, fetched, net };
166171
};
167172

168173
const req = (pathname) => ({ url: ORIGIN + pathname });
@@ -453,19 +458,44 @@ test('清除會清光所有快取,並把自動預快取關掉', async (load) =
453458
assert.equal(await sw.autoPrecacheEnabled(), false);
454459
});
455460

456-
test('清除之後不會馬上被自動下載回來', async (load) => {
461+
test('清除之後只補離線提示頁那一批,章節不會整包抓回來', async (load) => {
457462
const { sw, fetched } = load();
458463
await sw.clearAllOffline();
459464
fetched.length = 0;
460465

461466
// 模擬下一次導覽送 PRECACHE_LANG 過來
462-
if (await sw.autoPrecacheEnabled()) await sw.precacheFor('');
463-
assert.deepEqual(fetched, []);
467+
await sw.precacheFor('');
468+
assert.deepEqual(fetched.slice().sort(), sw.essentialUrlsFor('').slice().sort());
469+
assert.ok(fetched.length < sw.precacheUrlsFor('').length);
470+
assert.ok(!fetched.includes('/docs/tools/what-is-tor/'));
464471

465-
// 讀者自己在管理頁打開才恢復
472+
// 讀者自己在管理頁把開關打開才恢復完整
466473
await sw.setAutoPrecache(true);
467-
if (await sw.autoPrecacheEnabled()) await sw.precacheFor('');
468-
assert.ok(fetched.length > 0);
474+
fetched.length = 0;
475+
await sw.precacheFor('');
476+
assert.ok(fetched.includes('/docs/tools/what-is-tor/'));
477+
});
478+
479+
test('清除過的裝置離線時仍看得到離線提示頁', async (load) => {
480+
// 這一批底線存在的理由。少了它,沒快取過的網址在離線時會一路走到 networkFirst
481+
// 最後的 throw,讀者看到的是瀏覽器自己的網路錯誤畫面,不是站台的說明,而那一頁
482+
// 正好就是離線內容管理頁,想清東西的人往往正好連不上網。
483+
const { sw, net } = load();
484+
await sw.clearAllOffline();
485+
await sw.precacheFor('');
486+
net.offline = true;
487+
488+
const response = await sw.networkFirst(req('/docs/tools/what-is-tor/'), null);
489+
assert.equal(response.url, '/docs/offline/');
490+
});
491+
492+
test('關掉自動存之後,離線提示頁照樣留著', async (load) => {
493+
const { sw, net } = load();
494+
await sw.setAutoPrecache(false);
495+
await sw.precacheFor('');
496+
net.offline = true;
497+
498+
assert.equal((await sw.networkFirst(req('/docs/basics/metadata/'), null)).url, '/docs/offline/');
469499
});
470500

471501
test('狀態查詢回得出已存的、站台存的與空間用量', async (load) => {
@@ -509,6 +539,41 @@ test('認不得的指令會回錯誤,不會靜靜沒反應', async (load) => {
509539
assert.equal(replies[0].type, 'error');
510540
});
511541

542+
test('離線時沒快取過的頁面會落到離線頁', async (load) => {
543+
const { sw, caches } = load({ offline: true });
544+
const precache = await caches.open(sw.PRECACHE);
545+
await precache.put('/docs/offline/', 'ZH-OFFLINE');
546+
547+
const response = await sw.networkFirst(req('/docs/basics/metadata/'), null);
548+
// 走到這裡代表 networkFirst 沒有把錯誤往外丟。丟出去的話 respondWith 會 reject,
549+
// 讀者看到的是瀏覽器自己的網路錯誤畫面,站台的離線頁等於白做。
550+
assert.equal(response, 'ZH-OFFLINE');
551+
});
552+
553+
test('離線時快取過的頁面直接回快取,不落到離線頁', async (load) => {
554+
const { sw, caches } = load({ offline: true });
555+
const precache = await caches.open(sw.PRECACHE);
556+
await precache.put('/docs/offline/', 'ZH-OFFLINE');
557+
await precache.put('/docs/tools/what-is-tor/', 'TOR');
558+
559+
assert.equal(await sw.networkFirst(req('/docs/tools/what-is-tor/'), null), 'TOR');
560+
});
561+
562+
test('離線時各語系落到自己的離線頁', async (load) => {
563+
const { sw, caches } = load({ offline: true });
564+
const precache = await caches.open(sw.PRECACHE);
565+
await precache.put('/docs/offline/', 'ZH-OFFLINE');
566+
await precache.put('/docs/en/offline/', 'EN-OFFLINE');
567+
568+
assert.equal(await sw.networkFirst(req('/docs/en/basics/metadata/'), null), 'EN-OFFLINE');
569+
assert.equal(await sw.networkFirst(req('/docs/basics/metadata/'), null), 'ZH-OFFLINE');
570+
});
571+
572+
test('連離線頁都沒快取到時才把錯誤丟出去', async (load) => {
573+
const { sw } = load({ offline: true });
574+
await assert.rejects(() => sw.networkFirst(req('/docs/basics/metadata/'), null));
575+
});
576+
512577
for (const [name, fn] of tests) {
513578
try {
514579
await fn(load);

0 commit comments

Comments
 (0)