fix(recovery): verify offline browser state before banner (#4170)
This commit is contained in:
parent
544005c7ae
commit
85e26705da
3 changed files with 84 additions and 6 deletions
|
|
@ -3,6 +3,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
## [v0.51.410] — 2026-06-14 — Release NW (chat Mermaid lightbox + workspace CSV table preview, #4075/#4025)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
21
static/ui.js
21
static/ui.js
|
|
@ -85,16 +85,24 @@ async function _probeOfflineRecovery(){
|
||||||
try{return await _offlineHealthProbePromise;}
|
try{return await _offlineHealthProbePromise;}
|
||||||
finally{_offlineHealthProbePromise=null;}
|
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(){
|
async function checkOfflineRecoveryNow(){
|
||||||
if(_offlineProbePromise)return _offlineProbePromise;
|
if(_offlineProbePromise)return _offlineProbePromise;
|
||||||
_offlineProbePromise=(async()=>{
|
_offlineProbePromise=(async()=>{
|
||||||
if(!_offlineVisible)return false;
|
if(!_offlineVisible)return false;
|
||||||
if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}
|
|
||||||
_setOfflineChecking(true);
|
_setOfflineChecking(true);
|
||||||
const ok=await _probeOfflineRecovery();
|
const ok=await _probeOfflineRecovery();
|
||||||
_setOfflineChecking(false);
|
_setOfflineChecking(false);
|
||||||
if(ok){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;}
|
if(ok){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;}
|
||||||
showOfflineBanner('network');
|
showOfflineBanner(_browserReportsOnline()?'network':'browser');
|
||||||
return false;
|
return false;
|
||||||
})();
|
})();
|
||||||
try{return await _offlineProbePromise;}
|
try{return await _offlineProbePromise;}
|
||||||
|
|
@ -153,17 +161,18 @@ function _patchOfflineFetch(){
|
||||||
window.fetch=async function(...args){
|
window.fetch=async function(...args){
|
||||||
try{return await _offlineRawFetch(...args);}
|
try{return await _offlineRawFetch(...args);}
|
||||||
catch(e){
|
catch(e){
|
||||||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
if(!_isAbortError(e)&&(e instanceof TypeError||!_browserReportsOnline())){
|
||||||
else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');});
|
void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser');
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function initOfflineMonitor(){
|
function initOfflineMonitor(){
|
||||||
_patchOfflineFetch();
|
_patchOfflineFetch();
|
||||||
window.addEventListener('offline',()=>showOfflineBanner('browser'));
|
window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');});
|
||||||
window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
|
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});
|
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
|
||||||
else initOfflineMonitor();
|
else initOfflineMonitor();
|
||||||
|
|
|
||||||
65
tests/test_4170_offline_probe.py
Normal file
65
tests/test_4170_offline_probe.py
Normal file
|
|
@ -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;")
|
||||||
Loading…
Reference in a new issue