fix: restore inflight session on bfcache pageshow

This commit is contained in:
Dennis Soong
2026-05-03 01:53:01 +08:00
parent 5650d1107a
commit 5e806f6fd8
3 changed files with 106 additions and 21 deletions

View File

@@ -1357,7 +1357,7 @@ function applyBotName(){
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', (event) => {
window.addEventListener('pageshow', async (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
@@ -1367,6 +1367,17 @@ window.addEventListener('pageshow', (event) => {
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
// BFCache restores the frozen DOM without rerunning boot. Refresh the active
// session through the normal load path so in-flight sessions with
// active_stream_id / pending_user_message can reattach like a reload restore.
if (S.session && S.session.session_id && typeof loadSession === 'function') {
try {
await loadSession(S.session.session_id);
if (S.session && S.session.session_id && typeof checkInflightOnBoot === 'function') {
try { await checkInflightOnBoot(S.session.session_id); } catch (_) {}
}
} catch (_) {}
}
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}

View File

@@ -20,14 +20,30 @@ def _boot_js() -> str:
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
def _pageshow_handler(src: str) -> str:
marker = "window.addEventListener('pageshow'"
ps_idx = src.find(marker)
assert ps_idx != -1, "pageshow listener not found in boot.js"
brace = src.find("=> {", ps_idx)
assert brace != -1, "pageshow listener body not found"
brace = src.find("{", brace)
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, "pageshow listener body did not close"
return src[brace + 1 : i - 1]
class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_topbar(self):
"""pageshow handler must call syncTopbar() on bfcache restore."""
src = _boot_js()
# Find the pageshow listener block
ps_idx = src.find("window.addEventListener('pageshow'")
assert ps_idx != -1, "pageshow listener not found in boot.js"
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "syncTopbar" in handler_body, (
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
)
@@ -35,8 +51,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_workspace_panel_state(self):
"""pageshow handler must call syncWorkspacePanelState()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "syncWorkspacePanelState" in handler_body, (
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
)
@@ -45,8 +60,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_calls_start_gateway_sse(self):
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "startGatewaySSE" in handler_body, (
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
)
@@ -54,8 +68,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_still_clears_session_search(self):
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "sessionSearch" in handler_body, (
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
)
@@ -63,8 +76,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_still_calls_render_session_list_from_cache(self):
"""pageshow handler must still call renderSessionListFromCache()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "renderSessionListFromCache" in handler_body, (
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
)
@@ -73,8 +85,7 @@ class TestBfcacheLayoutRestore:
"""pageshow handler must NOT call _initResizePanels() — bfcache
preserves event listeners so re-attaching them stacks duplicates."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "_initResizePanels" not in handler_body, (
"pageshow handler must not call _initResizePanels() — it stacks "
"duplicate mousedown listeners on every bfcache restore"
@@ -83,8 +94,7 @@ class TestBfcacheLayoutRestore:
def test_new_calls_are_guarded_with_typeof(self):
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
# Each of the new calls must be guarded
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
@@ -95,8 +105,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_closes_all_dropdowns(self):
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert fn in handler_body, (
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
@@ -105,8 +114,7 @@ class TestBfcacheLayoutRestore:
def test_dropdowns_closed_before_layout_sync(self):
"""Dropdown closes must come before layout sync calls (clean state first)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
close_idx = handler_body.find("closeModelDropdown")
sync_idx = handler_body.find("syncTopbar")
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"

View File

@@ -0,0 +1,66 @@
"""Regression coverage for BFCache restore of an in-flight session (#1466).
A browser `pageshow` restore from BFCache does not re-run the boot IIFE. After
#1473, normal reload restores in-flight sessions through `loadSession()` and
`checkInflightOnBoot()`. BFCache restore should align with that path for the
currently viewed session instead of only refreshing layout chrome/sidebar cache.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
def _pageshow_handler() -> str:
marker = "window.addEventListener('pageshow'"
start = BOOT_JS.find(marker)
assert start != -1, "pageshow listener not found in boot.js"
brace = BOOT_JS.find("=> {", start)
assert brace != -1, "pageshow listener body not found"
brace = BOOT_JS.find("{", brace)
depth = 1
i = brace + 1
while i < len(BOOT_JS) and depth:
if BOOT_JS[i] == "{":
depth += 1
elif BOOT_JS[i] == "}":
depth -= 1
i += 1
assert depth == 0, "pageshow listener body did not close"
return BOOT_JS[brace + 1 : i - 1]
def test_pageshow_restores_active_inflight_session_through_load_session():
"""BFCache restore should refresh active session metadata and reattach stream.
The normal boot/reload path now calls loadSession(saved), which handles
active_stream_id / pending_user_message and reattaches via attachLiveStream().
The BFCache pageshow path must call the same loadSession path for the current
active session because the boot IIFE does not run again.
"""
body = _pageshow_handler()
assert "S.session" in body and "S.session.session_id" in body
assert "loadSession(S.session.session_id" in body
def test_pageshow_checks_persisted_inflight_marker_after_restoring_session():
"""BFCache restore should preserve the reload recovery marker behavior.
If localStorage still has an inflight marker, the pageshow path should reuse
checkInflightOnBoot() after restoring the active session, matching the normal
reload path without adding new server-side persistence behavior.
"""
body = _pageshow_handler()
load_pos = body.find("loadSession(S.session.session_id")
check_pos = body.find("checkInflightOnBoot(S.session.session_id")
assert load_pos != -1
assert check_pos != -1
assert load_pos < check_pos
def test_pageshow_active_session_restore_is_guarded_and_non_blocking():
"""pageshow handler must degrade safely when optional helpers are absent."""
body = _pageshow_handler()
assert "typeof loadSession === 'function'" in body
assert "typeof checkInflightOnBoot === 'function'" in body
assert "catch" in body[body.find("loadSession(S.session.session_id") : body.find("startGatewaySSE")]