From 64e764b9969e39d58468f3ae63a3f5df10f39080 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 01:32:11 +0000 Subject: [PATCH] fix: flush durable outboxes after reconnect (Closes #927) --- frontend/dashboard.js | 20 ++++++++----- frontend/index.html | 1 + frontend/reconnect-outboxes.js | 25 ++++++++++++++++ frontend/service-worker.js | 1 + tests/test_reconnect_outboxes.py | 51 ++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 6 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 frontend/reconnect-outboxes.js create mode 100644 tests/test_reconnect_outboxes.py diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8e9d2a4..0f9c130 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6962,19 +6962,25 @@ setStatus('Offline'); if (!hasContextSnapshot) await hydrateOfflineWork(); } - function reconnectLiveData() { - setStatus('Reconnecting…'); - contextPoller.refresh({ force: true }).then(snapshot => { + const reconnectOutboxes = createReconnectOutboxes({ + refresh: async () => { + setStatus('Reconnecting…'); + const snapshot = await contextPoller.refresh({ force: true }); if (!snapshot) { offlineStatus.hidden = false; setOfflineWorkMode(true); - return; + return null; } offlineStatus.hidden = true; setOfflineWorkMode(false); if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); - }); - } + return snapshot; + }, + restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }, + flushIssue: flushIssueOutbox, + flushAuthored: flushAuthoredOutbox, + flushNotificationReads: flushNotificationReadOutbox, + }); async function setOfflineWorkEnabled(enabled) { keepWorkOffline.checked = enabled; offlineWorkStore.setEnabled(enabled); @@ -7032,7 +7038,7 @@ updateDeliveryReceiptControls(); if (!navigator.onLine) await showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); - window.addEventListener('online', reconnectLiveData); + window.addEventListener('online', reconnectOutboxes); qs('#refresh').addEventListener('click', load); qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget)); diff --git a/frontend/index.html b/frontend/index.html index 53d7377..bf33e6d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1307,6 +1307,7 @@ + diff --git a/frontend/reconnect-outboxes.js b/frontend/reconnect-outboxes.js new file mode 100644 index 0000000..85f1fd2 --- /dev/null +++ b/frontend/reconnect-outboxes.js @@ -0,0 +1,25 @@ +function createReconnectOutboxes({ + refresh, + restoreIdentity, + flushIssue, + flushAuthored, + flushNotificationReads, +}) { + return async function reconnectOutboxes() { + const snapshot = await refresh(); + const freshness = snapshot?.freshness?.sections?.context; + const identityFresh = snapshot?.context && !snapshot.context.error && + !freshness?.stale && !freshness?.degraded && !freshness?.revalidating; + const login = identityFresh ? String(snapshot.context.user?.login || '').trim() : ''; + if (!login) return false; + restoreIdentity(login); + await Promise.all([ + flushIssue(), + flushAuthored(), + flushNotificationReads(), + ]); + return true; + }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createReconnectOutboxes; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c5c2774..18a21c8 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -32,6 +32,7 @@ const SHELL = [ BASE + 'static/draft-capacity-dialog.js', BASE + 'static/outbox-coordinator.js', BASE + 'static/issue-outbox.js', + BASE + 'static/reconnect-outboxes.js', BASE + 'static/authored-outbox.js', BASE + 'static/offline-issue-close.js', BASE + 'static/offline-issue-blocker.js', diff --git a/tests/test_reconnect_outboxes.py b/tests/test_reconnect_outboxes.py new file mode 100644 index 0000000..28b91ab --- /dev/null +++ b/tests/test_reconnect_outboxes.py @@ -0,0 +1,51 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RECONNECT = ROOT / "frontend" / "reconnect-outboxes.js" + + +def run_node(script: str) -> dict: + result = subprocess.run( + ["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True + ) + return json.loads(result.stdout) + + +def test_successful_reconnect_restores_identity_and_flushes_unchanged_outboxes(): + result = run_node( + f""" +const createReconnectOutboxes = require({json.dumps(str(RECONNECT))}); +const calls = []; +const reconnect = createReconnectOutboxes({{ + refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}), + restoreIdentity: login => calls.push(['identity', login]), + flushIssue: () => calls.push(['issue']), + flushAuthored: () => calls.push(['authored']), + flushNotificationReads: () => calls.push(['notification-read']), +}}); +reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}}))); +""" + ) + + assert result == { + "result": True, + "calls": [ + ["identity", "timmy"], + ["issue"], + ["authored"], + ["notification-read"], + ], + } + + +def test_dashboard_uses_reconnect_flush_after_live_identity_refresh(): + index = (ROOT / "frontend" / "index.html").read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + + assert '' in index + assert "const reconnectOutboxes = createReconnectOutboxes({" in dashboard + assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard + assert "window.addEventListener('online', reconnectOutboxes);" in dashboard diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index caadc02..68342ab 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -930,6 +930,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/draft-capacity-dialog.js", "/dashboard/static/outbox-coordinator.js", "/dashboard/static/issue-outbox.js", + "/dashboard/static/reconnect-outboxes.js", "/dashboard/static/authored-outbox.js", "/dashboard/static/offline-issue-close.js", "/dashboard/static/offline-issue-blocker.js",