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

在線卻卡在離線頁時,offline.html 會自動嘗試修復 Service Worker 並導回主應用,避免無法進入主功能的死亡迴圈。
163 changes: 150 additions & 13 deletions apps/ratewise/public/offline.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
<h1>您目前處於離線狀態</h1>
<p>請檢查您的網路連線,或稍後再試。HaoRate 會在連線恢復時自動更新。</p>

<button class="retry-btn" onclick="window.location.reload()">
<button class="retry-btn" id="retry-btn" type="button">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
Expand Down Expand Up @@ -227,20 +227,157 @@ <h2>小提示</h2>
</div>

<script>
// 檢查是否有快取的匯率數據
try {
const cached = localStorage.getItem('exchangeRates');
if (cached) {
document.getElementById('cached-info').style.display = 'block';
(function () {
var HEAL_SESSION_KEY = 'rw-offline-shell-heal';
var LEGACY_PRECACHE_THRESHOLD = 150;
var SHELL_PROBE_TIMEOUT_MS = 2000;
var WAITING_SW_TIMEOUT_MS = 8000;

// 檢查是否有快取的匯率數據
try {
var cached = localStorage.getItem('exchangeRates');
if (cached) {
document.getElementById('cached-info').style.display = 'block';
}
} catch (e) {
// localStorage 不可用時略過
}
} catch (e) {
// Ignore localStorage errors
}

// 監聽網路狀態變化,自動重載
window.addEventListener('online', () => {
window.location.reload();
});
function hasHealBeenAttemptedThisSession() {
try {
return sessionStorage.getItem(HEAL_SESSION_KEY) === '1';
} catch (e) {
return false;
}
}

function markHealAttemptedThisSession() {
try {
sessionStorage.setItem(HEAL_SESSION_KEY, '1');
} catch (e) {
// sessionStorage 不可用時仍允許單次修復
}
}

function navigateToAppShell() {
var healQuery = 'shell-heal=' + Date.now();
window.location.replace('./?' + healQuery);
}

function probeActiveSwShellBroken() {
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {
return Promise.resolve(true);
}

return new Promise(function (resolve) {
var channel = new MessageChannel();
var timeoutId = setTimeout(function () {
resolve(true);
}, SHELL_PROBE_TIMEOUT_MS);

channel.port1.onmessage = function (event) {
clearTimeout(timeoutId);
var data = event.data || {};
var precacheEntryCount = Number(data.precacheEntryCount || 0);
var hasIndexShell = Boolean(data.hasIndexShell || data.healthy);
resolve(!hasIndexShell || precacheEntryCount > LEGACY_PRECACHE_THRESHOLD);
};

navigator.serviceWorker.controller.postMessage({ type: 'CHECK_SHELL_PRECACHE' }, [
channel.port2,
]);
});
}

function waitForWaitingServiceWorker(registration) {
if (registration.waiting) {
return Promise.resolve(registration.waiting);
}

return new Promise(function (resolve) {
var timeoutId = setTimeout(function () {
registration.removeEventListener('updatefound', onUpdateFound);
resolve(registration.waiting);
}, WAITING_SW_TIMEOUT_MS);

function onUpdateFound() {
var installing = registration.installing;
if (!installing) {
return;
}

installing.addEventListener('statechange', function () {
if (installing.state === 'installed' && registration.waiting) {
clearTimeout(timeoutId);
registration.removeEventListener('updatefound', onUpdateFound);
resolve(registration.waiting);
}
});
}

registration.addEventListener('updatefound', onUpdateFound);
});
}

function skipWaitingAndNavigateToShell(waitingWorker) {
return new Promise(function () {
navigator.serviceWorker.addEventListener(
'controllerchange',
function () {
navigateToAppShell();
},
{ once: true },
);
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
setTimeout(navigateToAppShell, 1500);
});
}

// 在線卻載入 offline.html 時自我修復;swHealth 僅在 React 掛載後執行,無法打破死亡迴圈。
async function attemptOfflineShellSelfHeal(forceRetry) {
if (!navigator.onLine) {
return;
}
if (!forceRetry && hasHealBeenAttemptedThisSession()) {
return;
}
markHealAttemptedThisSession();

try {
if ('serviceWorker' in navigator) {
var registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
var broken = await probeActiveSwShellBroken();
if (broken) {
var waiting = await waitForWaitingServiceWorker(registration);
if (waiting) {
await skipWaitingAndNavigateToShell(waiting);
return;
}
}
}
}
} catch (e) {
// 修復失敗時仍嘗試導向 app shell
}

navigateToAppShell();
}

var retryBtn = document.getElementById('retry-btn');
if (retryBtn) {
retryBtn.addEventListener('click', function () {
void attemptOfflineShellSelfHeal(true);
});
}

window.addEventListener('online', function () {
void attemptOfflineShellSelfHeal(true);
});

void attemptOfflineShellSelfHeal(false);
})();
</script>
</body>
</html>
163 changes: 150 additions & 13 deletions apps/ratewise/scripts/templates/offline.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
<h1>您目前處於離線狀態</h1>
<p>請檢查您的網路連線,或稍後再試。__BRAND_SHORT__ 會在連線恢復時自動更新。</p>

<button class="retry-btn" onclick="window.location.reload()">
<button class="retry-btn" id="retry-btn" type="button">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
Expand Down Expand Up @@ -227,20 +227,157 @@ <h2>小提示</h2>
</div>

<script>
// 檢查是否有快取的匯率數據
try {
const cached = localStorage.getItem('exchangeRates');
if (cached) {
document.getElementById('cached-info').style.display = 'block';
(function () {
var HEAL_SESSION_KEY = 'rw-offline-shell-heal';
var LEGACY_PRECACHE_THRESHOLD = 150;
var SHELL_PROBE_TIMEOUT_MS = 2000;
var WAITING_SW_TIMEOUT_MS = 8000;

// 檢查是否有快取的匯率數據
try {
var cached = localStorage.getItem('exchangeRates');
if (cached) {
document.getElementById('cached-info').style.display = 'block';
}
} catch (e) {
// localStorage 不可用時略過
}
} catch (e) {
// Ignore localStorage errors
}

// 監聽網路狀態變化,自動重載
window.addEventListener('online', () => {
window.location.reload();
});
function hasHealBeenAttemptedThisSession() {
try {
return sessionStorage.getItem(HEAL_SESSION_KEY) === '1';
} catch (e) {
return false;
}
}

function markHealAttemptedThisSession() {
try {
sessionStorage.setItem(HEAL_SESSION_KEY, '1');
} catch (e) {
// sessionStorage 不可用時仍允許單次修復
}
}

function navigateToAppShell() {
var healQuery = 'shell-heal=' + Date.now();
window.location.replace('./?' + healQuery);
}

function probeActiveSwShellBroken() {
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {
return Promise.resolve(true);
}

return new Promise(function (resolve) {
var channel = new MessageChannel();
var timeoutId = setTimeout(function () {
resolve(true);
}, SHELL_PROBE_TIMEOUT_MS);

channel.port1.onmessage = function (event) {
clearTimeout(timeoutId);
var data = event.data || {};
var precacheEntryCount = Number(data.precacheEntryCount || 0);
var hasIndexShell = Boolean(data.hasIndexShell || data.healthy);
resolve(!hasIndexShell || precacheEntryCount > LEGACY_PRECACHE_THRESHOLD);
};

navigator.serviceWorker.controller.postMessage({ type: 'CHECK_SHELL_PRECACHE' }, [
channel.port2,
]);
});
}

function waitForWaitingServiceWorker(registration) {
if (registration.waiting) {
return Promise.resolve(registration.waiting);
}

return new Promise(function (resolve) {
var timeoutId = setTimeout(function () {
registration.removeEventListener('updatefound', onUpdateFound);
resolve(registration.waiting);
}, WAITING_SW_TIMEOUT_MS);

function onUpdateFound() {
var installing = registration.installing;
if (!installing) {
return;
}

installing.addEventListener('statechange', function () {
if (installing.state === 'installed' && registration.waiting) {
clearTimeout(timeoutId);
registration.removeEventListener('updatefound', onUpdateFound);
resolve(registration.waiting);
}
});
}

registration.addEventListener('updatefound', onUpdateFound);
});
}

function skipWaitingAndNavigateToShell(waitingWorker) {
return new Promise(function () {
navigator.serviceWorker.addEventListener(
'controllerchange',
function () {
navigateToAppShell();
},
{ once: true },
);
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
setTimeout(navigateToAppShell, 1500);
});
}

// 在線卻載入 offline.html 時自我修復;swHealth 僅在 React 掛載後執行,無法打破死亡迴圈。
async function attemptOfflineShellSelfHeal(forceRetry) {
if (!navigator.onLine) {
return;
}
if (!forceRetry && hasHealBeenAttemptedThisSession()) {
return;
}
markHealAttemptedThisSession();

try {
if ('serviceWorker' in navigator) {
var registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
var broken = await probeActiveSwShellBroken();
if (broken) {
var waiting = await waitForWaitingServiceWorker(registration);
if (waiting) {
await skipWaitingAndNavigateToShell(waiting);
return;
}
}
}
}
} catch (e) {
// 修復失敗時仍嘗試導向 app shell
}

navigateToAppShell();
}

var retryBtn = document.getElementById('retry-btn');
if (retryBtn) {
retryBtn.addEventListener('click', function () {
void attemptOfflineShellSelfHeal(true);
});
}

window.addEventListener('online', function () {
void attemptOfflineShellSelfHeal(true);
});

void attemptOfflineShellSelfHeal(false);
})();
</script>
</body>
</html>
16 changes: 14 additions & 2 deletions apps/ratewise/src/pwa-offline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,23 @@ describe('PWA 離線功能測試', () => {
const offlinePath = resolve(ROOT_PATH, 'public/offline.html');
const content = readFileSync(offlinePath, 'utf-8');

// 應該有重試功能
expect(content).toContain('window.location.reload()');
// 重試按鈕應觸發 shell 自我修復,而非單純 reload(避免在線死亡迴圈)
expect(content).toContain('retry-btn');
expect(content).toContain('attemptOfflineShellSelfHeal');
expect(content).toContain("window.addEventListener('online'");
});

it('should self-heal when online user lands on offline shell', () => {
const offlinePath = resolve(ROOT_PATH, 'public/offline.html');
const content = readFileSync(offlinePath, 'utf-8');

expect(content).toContain('rw-offline-shell-heal');
expect(content).toContain('CHECK_SHELL_PRECACHE');
expect(content).toContain("type: 'SKIP_WAITING'");
expect(content).toContain('navigateToAppShell');
expect(content).not.toContain('window.location.reload()');
});

it('should show cached data indicator', () => {
const offlinePath = resolve(ROOT_PATH, 'public/offline.html');
const content = readFileSync(offlinePath, 'utf-8');
Expand Down
Loading
Loading