diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1a4e850..9a13d51 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7331,6 +7331,9 @@ flushAuthored: flushAuthoredOutbox, flushNotificationReads: flushNotificationReadOutbox, }); + const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500); + window.addEventListener('online', reconnectAfterOnline); + workspaceLifecycle.replayOnline(reconnectAfterOnline); async function setOfflineWorkEnabled(enabled) { keepWorkOffline.checked = enabled; offlineWorkStore.setEnabled(enabled); @@ -7388,9 +7391,6 @@ updateDeliveryReceiptControls(); if (!navigator.onLine) await showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); - const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500); - window.addEventListener('online', reconnectAfterOnline); - workspaceLifecycle.replayOnline(reconnectAfterOnline); qs('#refresh').addEventListener('click', load); qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget)); diff --git a/frontend/reconnect-outboxes.js b/frontend/reconnect-outboxes.js index 85f1fd2..e304299 100644 --- a/frontend/reconnect-outboxes.js +++ b/frontend/reconnect-outboxes.js @@ -5,7 +5,10 @@ function createReconnectOutboxes({ flushAuthored, flushNotificationReads, }) { - return async function reconnectOutboxes() { + let activeReconnect = null; + let followUpRequested = false; + + async function reconnectOnce() { const snapshot = await refresh(); const freshness = snapshot?.freshness?.sections?.context; const identityFresh = snapshot?.context && !snapshot.context.error && @@ -19,6 +22,22 @@ function createReconnectOutboxes({ flushNotificationReads(), ]); return true; + } + + return function reconnectOutboxes() { + if (activeReconnect) { + followUpRequested = true; + return activeReconnect; + } + activeReconnect = (async () => { + let result = false; + do { + followUpRequested = false; + result = await reconnectOnce(); + } while (followUpRequested); + return result; + })().finally(() => { activeReconnect = null; }); + return activeReconnect; }; } diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index 7698292..7a56902 100644 --- a/frontend/workspace-bootstrap.js +++ b/frontend/workspace-bootstrap.js @@ -25,9 +25,14 @@ async function loadWorkspace({ document, window = null, createLoader = createFea }; } catch (error) { window?.removeEventListener('online', captureOnline); + if (window?.location?.reload) { + if (cameOnline) window.location.reload(); + else window.addEventListener('online', () => window.location.reload(), { once: true }); + } if (status) { - status.textContent = - 'Workspace could not load. Check your connection, then reload to retry.'; + status.textContent = window + ? 'Workspace could not load. Reconnect to retry automatically, or reload now.' + : 'Workspace could not load. Check your connection, then reload to retry.'; } throw error; } diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index ae20ea2..2ad2bbd 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -275,7 +275,19 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo if fake.created_issues: break page.wait_for_timeout(250) - assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}] + assert fake.created_issues == [ + {"title": TITLE, "body": BODY, "assignee": "timmy"} + ], ( + f"url={page.url} ready={page.evaluate('document.readyState')} " + f"status={page.locator('#my-work-action-status').inner_text()!r} " + f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} " + f"requests={fake.requests[-20:]!r}" + ) + # Deferred workspace startup can report its already-issued offline request + # after the pre-reconnect clear; retain every non-offline browser error. + browser_errors[:] = [ + error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error + ] for _ in range(40): durable_completion = indexed_issue_records(page) diff --git a/tests/test_reconnect_outboxes.py b/tests/test_reconnect_outboxes.py index 01485dc..969da9e 100644 --- a/tests/test_reconnect_outboxes.py +++ b/tests/test_reconnect_outboxes.py @@ -41,6 +41,36 @@ reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}} } +def test_concurrent_reconnect_requests_queue_one_follow_up_flush(): + result = run_node( + f""" +const createReconnectOutboxes = require({json.dumps(str(RECONNECT))}); +(async () => {{ +let releaseFirst; let issueCalls=0; let active=0; let maxActive=0; +const reconnect = createReconnectOutboxes({{ + refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}), + restoreIdentity: () => {{}}, + flushIssue: async () => {{ + issueCalls++; active++; maxActive=Math.max(maxActive,active); + if (issueCalls === 1) await new Promise(resolve => {{ releaseFirst=resolve; }}); + active--; + }}, + flushAuthored: async () => {{}}, + flushNotificationReads: async () => {{}}, +}}); +const first=reconnect(); +while (!releaseFirst) await new Promise(resolve=>setTimeout(resolve,0)); +const second=reconnect(); +releaseFirst(); +await Promise.all([first,second]); +process.stdout.write(JSON.stringify({{issueCalls,maxActive}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + ) + + assert result == {"issueCalls": 2, "maxActive": 1} + + def test_dashboard_uses_reconnect_flush_after_live_identity_refresh(): index = (ROOT / "frontend" / "index.html").read_text() dashboard = (ROOT / "frontend" / "dashboard.js").read_text() @@ -50,3 +80,12 @@ def test_dashboard_uses_reconnect_flush_after_live_identity_refresh(): assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard assert "window.addEventListener('online', reconnectAfterOnline);" in dashboard assert "setTimeout(reconnectOutboxes, 500)" in dashboard + + +def test_dashboard_registers_reconnect_before_offline_startup_can_wait(): + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + + listener = dashboard.index("window.addEventListener('online', reconnectAfterOnline);") + offline_startup = dashboard.index("await updateOfflineWorkControls();") + + assert listener < offline_startup diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index 6751822..f13c73f 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -56,6 +56,52 @@ console.log(JSON.stringify({message,status:status.textContent})); } +def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; let reloads=0; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; +}}; +const window={ + location:{reload(){reloads++;}}, + addEventListener(name,callback) { listeners[name]=callback; }, + removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, +}; +const createLoader=()=>({load:async()=>{throw new Error('offline');}}); +try { await loadWorkspace({document,window,createLoader}); } catch (error) {} +const waiting=Boolean(listeners.online); +if (listeners.online) listeners.online(); +console.log(JSON.stringify({waiting,reloads,status:status.textContent})); +""") + assert result == { + "waiting": True, + "reloads": 1, + "status": "Workspace could not load. Reconnect to retry automatically, or reload now.", + } + + +def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; let reloads=0; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; +}}; +const window={ + location:{reload(){reloads++;}}, + addEventListener(name,callback) { listeners[name]=callback; }, + removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, +}; +const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}}); +try { await loadWorkspace({document,window,createLoader}); } catch (error) {} +console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)})); +""") + assert result == {"reloads": 1, "waiting": False} + + def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers(): result = run_bootstrap(""" const status={textContent:''}; const listeners={};