gate-fix #5467 r2: force render when recovering from skeleton/error-banner DOM

Codex re-gate found 2 more false-skips (DOM state OUTSIDE the signature path):
- CORE: profile-switch skeleton stays visible if the new payload signature
  matches the last one (empty/same-shaped profile) — skip fires over the skeleton.
- SILENT: stale 'Could not load conversations' banner persists when a transient
  fetch failure heals with identical rows.
Fix: capture _hadSessionListSkeleton + _hadSessionListLoadError BEFORE clearing
them, and disallow the identical-signature skip when either was set. + regression
test. (messages.js:1462 finding is a base-comparison artifact for #5472/#5479/#5484,
not in #5467's diff.)
This commit is contained in:
nesquena-hermes 2026-07-03 23:04:41 +00:00
parent 3999418b0e
commit b438976507
2 changed files with 30 additions and 1 deletions

View file

@ -4436,6 +4436,11 @@ function _applySessionListPayload(sessData, projData){
_syncSessionAttentionSoundState(_allSessions);
_pruneLineageReportCacheToVisibleSessions(_allSessions);
_allProjects = projData.projects||[];
// Capture the recovering-from-error state BEFORE clearing it: the error banner
// DOM was rendered outside the signature path, so if this payload heals with
// rows identical to the last render, the identical-signature skip below would
// leave the stale "Could not load conversations" banner on screen. (Codex #5467)
const _hadSessionListLoadError = !!_sessionListLoadError;
_sessionListLoadError = null;
_sessionListHasLoadedOnce = true;
_markPollingCompletionUnreadTransitions(_allSessions);
@ -4457,6 +4462,7 @@ function _applySessionListPayload(sessData, projData){
// holds the CURRENT profile's rows. Clear the skeleton flag right before painting so this
// authoritative render replaces the profile-switch skeleton — while unrelated renders that
// fire before this point stay blocked by the guard in renderSessionListFromCache().
const _hadSessionListSkeleton = _sessionListSkeletonActive;
_sessionListSkeletonActive = false;
// No-op fast path: if this payload renders identically to what is already on
// screen (the common case for idle polls) and no entrance animation is
@ -4465,9 +4471,13 @@ function _applySessionListPayload(sessData, projData){
// renderSessionListFromCache directly and are unaffected. Guarded by the same
// conditions renderSessionListFromCache bails on, so a bailed render never
// caches a signature that would suppress the next real repaint. (#5455 WS2.4)
// NEVER skip when recovering from a skeleton or error-banner DOM state: those
// are rendered outside the signature path, so an identical-signature match
// would leave the skeleton/error on screen instead of the real list. (Codex #5467)
const _canRenderNow = !_renamingSid && !_sessionActionMenu;
const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError;
const _renderSig = _sessionListRenderSignature();
if(_canRenderNow && !_sessionListRefreshAnimationPending && _renderSig && _renderSig===_lastSessionListRenderSig){
if(_canRenderNow && !_mustForceRender && !_sessionListRefreshAnimationPending && _renderSig && _renderSig===_lastSessionListRenderSig){
// Preserve the per-refresh INFLIGHT cleanup that renderSessionListFromCache
// would otherwise perform, then skip only the DOM rebuild.
if(typeof _purgeStaleInflightEntries==='function') _purgeStaleInflightEntries();

View file

@ -176,6 +176,25 @@ def test_session_list_render_signature_changes_on_pending_running_and_attention(
assert "JSON.stringify([" in block
def test_session_list_render_signature_does_not_skip_recovering_from_skeleton_or_error():
"""The render-skip must never fire when recovering from a skeleton or the
'Could not load conversations' banner — both are rendered OUTSIDE the
signature path, so an identical-signature match (empty/same-shaped profile,
or a transient fetch failure healing with identical rows) would leave the
skeleton/error DOM on screen instead of the real list. (Codex #5467 re-gate)
"""
start = SESSIONS_JS.find("function _applySessionListPayload(")
assert start != -1
body = SESSIONS_JS[start:start + 6000]
assert "const _hadSessionListSkeleton = _sessionListSkeletonActive;" in body
assert "const _hadSessionListLoadError = !!_sessionListLoadError;" in body
assert "const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError;" in body
assert "!_mustForceRender" in body, "the force flag must gate the identical-signature skip"
# captured BEFORE the respective clears
assert body.index("const _hadSessionListSkeleton") < body.index("_sessionListSkeletonActive = false;")
assert body.index("const _hadSessionListLoadError") < body.index("_sessionListLoadError = null;")
def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available():
assert "window.refreshSessionList('pull', {force:true, refreshActive:true})" in UI_JS
assert "Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset)" in UI_JS