From 85e26705da39daa276c5fc27e4a901bd3b7c0a45 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 14 Jun 2026 09:55:43 -0400 Subject: [PATCH 1/3] fix(recovery): verify offline browser state before banner (#4170) --- CHANGELOG.md | 4 ++ static/ui.js | 21 ++++++++--- tests/test_4170_offline_probe.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/test_4170_offline_probe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fed95a5a..f7e486f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Offline recovery now verifies unreliable browser offline reports with the existing `/health` probe before showing or sticking on the browser-reason banner (#4170).** `navigator.onLine` is treated as a hint for the banner reason, while server reachability remains the source of truth, so browsers that incorrectly report offline can recover without a page reload or permanent connection-lost state. (#4170) + ## [v0.51.410] — 2026-06-14 — Release NW (chat Mermaid lightbox + workspace CSV table preview, #4075/#4025) ### Added diff --git a/static/ui.js b/static/ui.js index 8b801696..588ee007 100644 --- a/static/ui.js +++ b/static/ui.js @@ -85,16 +85,24 @@ async function _probeOfflineRecovery(){ try{return await _offlineHealthProbePromise;} finally{_offlineHealthProbePromise=null;} } +async function _showOfflineBannerIfProbeFails(reason){ + const ok=await _probeOfflineRecovery(); + if(ok){ + if(_offlineVisible){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();} + return true; + } + showOfflineBanner(reason||(_browserReportsOnline()?'network':'browser')); + return false; +} async function checkOfflineRecoveryNow(){ if(_offlineProbePromise)return _offlineProbePromise; _offlineProbePromise=(async()=>{ if(!_offlineVisible)return false; - if(!_browserReportsOnline()){showOfflineBanner('browser');return false;} _setOfflineChecking(true); const ok=await _probeOfflineRecovery(); _setOfflineChecking(false); if(ok){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;} - showOfflineBanner('network'); + showOfflineBanner(_browserReportsOnline()?'network':'browser'); return false; })(); try{return await _offlineProbePromise;} @@ -153,17 +161,18 @@ function _patchOfflineFetch(){ window.fetch=async function(...args){ try{return await _offlineRawFetch(...args);} catch(e){ - if(!_browserReportsOnline())showOfflineBanner('browser'); - else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');}); + if(!_isAbortError(e)&&(e instanceof TypeError||!_browserReportsOnline())){ + void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser'); + } throw e; } }; } function initOfflineMonitor(){ _patchOfflineFetch(); - window.addEventListener('offline',()=>showOfflineBanner('browser')); + window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');}); window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();}); - if(!_browserReportsOnline())showOfflineBanner('browser'); + if(!_browserReportsOnline())void _showOfflineBannerIfProbeFails('browser'); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true}); else initOfflineMonitor(); diff --git a/tests/test_4170_offline_probe.py b/tests/test_4170_offline_probe.py new file mode 100644 index 00000000..15801f3c --- /dev/null +++ b/tests/test_4170_offline_probe.py @@ -0,0 +1,65 @@ +"""Regression coverage for unreliable navigator.onLine offline reports.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _strip_comments(src: str) -> str: + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return re.sub(r"//.*", "", src) + + +def _fn_body(src: str, marker: str) -> str: + idx = src.find(marker) + assert idx != -1, f"{marker!r} not found" + brace = src.find("{", idx) + assert brace != -1, f"{marker!r} has no body" + depth = 1 + i = brace + 1 + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + assert depth == 0, f"{marker!r} body did not close" + return src[brace + 1 : i - 1] + + +def test_recovery_loop_probes_even_when_browser_reports_offline(): + body = _strip_comments(_fn_body(UI_JS, "async function checkOfflineRecoveryNow(")) + assert "_probeOfflineRecovery()" in body + assert "if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}" not in body + assert body.find("_probeOfflineRecovery()") < body.find("showOfflineBanner(") + assert "showOfflineBanner(_browserReportsOnline()?'network':'browser')" in body + + +def test_probe_backed_browser_banner_helper_uses_health_as_source_of_truth(): + body = _strip_comments(_fn_body(UI_JS, "async function _showOfflineBannerIfProbeFails(")) + assert "const ok=await _probeOfflineRecovery();" in body + assert "if(ok)" in body + assert "showOfflineBanner(reason||(_browserReportsOnline()?'network':'browser'))" in body + assert body.find("_probeOfflineRecovery()") < body.find("showOfflineBanner(") + + +def test_offline_event_and_startup_false_online_probe_before_browser_banner(): + body = _strip_comments(_fn_body(UI_JS, "function initOfflineMonitor(")) + assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" not in body + assert "if(!_browserReportsOnline())showOfflineBanner('browser');" not in body + assert "window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');});" in body + assert "if(!_browserReportsOnline())void _showOfflineBannerIfProbeFails('browser');" in body + + +def test_fetch_catch_probes_before_browser_banner_and_rethrows(): + body = _strip_comments(_fn_body(UI_JS, "function _patchOfflineFetch(")) + fetch_body = body.split("window.fetch=async function(...args){", 1)[1] + assert "if(!_browserReportsOnline())showOfflineBanner('browser');" not in fetch_body + assert "void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser');" in fetch_body + assert "throw e;" in fetch_body + assert fetch_body.find("_showOfflineBannerIfProbeFails(") < fetch_body.find("throw e;") From 3377aae9f278834dccc79cb0691ebcd0c07e59b9 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 14 Jun 2026 10:04:22 -0400 Subject: [PATCH 2/3] test(recovery): align offline banner assertions with probe flow --- tests/test_offline_banner.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_offline_banner.py b/tests/test_offline_banner.py index 31279999..3440d4f3 100644 --- a/tests/test_offline_banner.py +++ b/tests/test_offline_banner.py @@ -36,7 +36,7 @@ def test_offline_banner_markup_styles_and_copy_exist(): def test_offline_monitor_patches_fetch_and_auto_reloads_after_health_probe(): assert "const OFFLINE_RECHECK_MS=2500" in UI_JS assert "window.fetch=async function(...args)" in UI_JS - assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" in UI_JS + assert "window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');});" in UI_JS assert "window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();})" in UI_JS assert "setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS)" in UI_JS assert "new URL('health',document.baseURI||location.href)" in UI_JS @@ -58,10 +58,9 @@ def test_offline_recovery_probe_is_serialized_and_stops_timer_before_reload(): def test_fetch_typeerror_is_gated_by_health_probe_not_blind_banner(): fetch_patch = UI_JS.split("window.fetch=async function(...args){", 1)[1].split("function initOfflineMonitor", 1)[0] assert "function _isAbortError(e)" in UI_JS - assert "e instanceof TypeError&&!_isAbortError(e)" in fetch_patch - assert "void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');})" in fetch_patch - assert "if(!_browserReportsOnline())showOfflineBanner('browser');" in fetch_patch - assert "e instanceof TypeError||!_browserReportsOnline()" not in fetch_patch + assert "!_isAbortError(e)&&(e instanceof TypeError||!_browserReportsOnline())" in fetch_patch + assert "void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser');" in fetch_patch + assert "if(!_browserReportsOnline())showOfflineBanner('browser');" not in fetch_patch def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error(): From 11f50350f0404308e7cd590afbe7aec72128f79b Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 14 Jun 2026 10:20:21 -0400 Subject: [PATCH 3/3] fix(recovery): prevent duplicate offline soft recovery --- CHANGELOG.md | 2 +- static/ui.js | 5 ++++- tests/test_4170_offline_probe.py | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e486f5..fe1f161d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed -- **Offline recovery now verifies unreliable browser offline reports with the existing `/health` probe before showing or sticking on the browser-reason banner (#4170).** `navigator.onLine` is treated as a hint for the banner reason, while server reachability remains the source of truth, so browsers that incorrectly report offline can recover without a page reload or permanent connection-lost state. (#4170) +- **Offline recovery now verifies unreliable browser offline reports with the existing `/health` probe before showing or sticking on the browser-reason banner.** `navigator.onLine` is treated as a hint for the banner reason, while server reachability remains the source of truth, so browsers that incorrectly report offline can recover without a page reload or permanent connection-lost state. (#4170) ## [v0.51.410] — 2026-06-14 — Release NW (chat Mermaid lightbox + workspace CSV table preview, #4075/#4025) diff --git a/static/ui.js b/static/ui.js index 588ee007..cf84abec 100644 --- a/static/ui.js +++ b/static/ui.js @@ -86,7 +86,10 @@ async function _probeOfflineRecovery(){ finally{_offlineHealthProbePromise=null;} } async function _showOfflineBannerIfProbeFails(reason){ + const visibleAtStart=_offlineVisible; + if(visibleAtStart)_setOfflineChecking(true); const ok=await _probeOfflineRecovery(); + if(visibleAtStart)_setOfflineChecking(false); if(ok){ if(_offlineVisible){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();} return true; @@ -101,7 +104,7 @@ async function checkOfflineRecoveryNow(){ _setOfflineChecking(true); const ok=await _probeOfflineRecovery(); _setOfflineChecking(false); - if(ok){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;} + if(ok){if(!_offlineVisible)return true;_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;} showOfflineBanner(_browserReportsOnline()?'network':'browser'); return false; })(); diff --git a/tests/test_4170_offline_probe.py b/tests/test_4170_offline_probe.py index 15801f3c..69217b8c 100644 --- a/tests/test_4170_offline_probe.py +++ b/tests/test_4170_offline_probe.py @@ -37,12 +37,16 @@ def test_recovery_loop_probes_even_when_browser_reports_offline(): assert "_probeOfflineRecovery()" in body assert "if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}" not in body assert body.find("_probeOfflineRecovery()") < body.find("showOfflineBanner(") + assert "if(ok){if(!_offlineVisible)return true;_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;}" in body assert "showOfflineBanner(_browserReportsOnline()?'network':'browser')" in body def test_probe_backed_browser_banner_helper_uses_health_as_source_of_truth(): body = _strip_comments(_fn_body(UI_JS, "async function _showOfflineBannerIfProbeFails(")) + assert "const visibleAtStart=_offlineVisible;" in body + assert "if(visibleAtStart)_setOfflineChecking(true);" in body assert "const ok=await _probeOfflineRecovery();" in body + assert "if(visibleAtStart)_setOfflineChecking(false);" in body assert "if(ok)" in body assert "showOfflineBanner(reason||(_browserReportsOnline()?'network':'browser'))" in body assert body.find("_probeOfflineRecovery()") < body.find("showOfflineBanner(")