From d80091d2445352adc1a4b34bee401fec3f3c5aee Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 17:25:48 +0000 Subject: [PATCH] fix: preserve offline workflows across activation (Closes #977) --- frontend/service-worker.js | 24 ++++++-- tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 72 ++++++++++++++++++----- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 11 files changed, 84 insertions(+), 30 deletions(-) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 3b5a917..f8352f5 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v113'; +const CACHE = 'stackchain-dashboard-shell-v114'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -366,15 +366,29 @@ self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())); }); +async function warmOptionalFeature(cache, staleCacheNames, asset) { + for (const cacheName of staleCacheNames) { + const staleCache = await caches.open(cacheName); + const cached = await staleCache.match(asset); + if (cached) { + await cache.put(asset, cached); + return; + } + } + await cache.add(asset); +} + self.addEventListener('activate', event => { event.waitUntil((async () => { const keys = await caches.keys(); - await Promise.all( - keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE) - .map(key => caches.delete(key)) + const staleCacheNames = keys.filter( + key => key.startsWith('stackchain-dashboard-') && key !== CACHE ); const cache = await caches.open(CACHE); - await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset))); + await Promise.allSettled( + OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset)) + ); + await Promise.all(staleCacheNames.map(key => caches.delete(key))); await self.clients.claim(); })()); }); diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 1dba1d3..6e52e78 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v113" in worker + assert "stackchain-dashboard-shell-v114" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index c55fe9b..94bcbcf 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 8b3d0c7..37908c3 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v113" in worker + assert "stackchain-dashboard-shell-v114" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 8ebe91f..367b31a 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v113" in worker + assert "stackchain-dashboard-shell-v114" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 5737064..ae2b0a9 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v113" in worker + assert "stackchain-dashboard-shell-v114" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index c6937c6..0616cc3 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v113" in service_worker + assert "stackchain-dashboard-shell-v114" in service_worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 5d13f44..843fd03 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3f64a53..62d2117 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -26,6 +26,7 @@ storedResponses.set( const cache = {{ addAll: async urls => {{ state.added = urls; }}, add: async url => {{ + state.addAttempts.push(url); if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable'); state.individuallyAdded.push(url); }}, @@ -37,9 +38,18 @@ const cache = {{ put: async (request, response) => {{ const key = String(request.url || request); state.puts.push(key); + state.migrated.push(key); + state.activationOrder.push('put:' + key); storedResponses.set(key, response.clone()); }}, }}; +const oldCache = {{ + match: async request => {{ + const key = String(request.url || request); + const body = state.oldCachedAssets[key]; + return body === undefined ? null : new Response(body); + }}, +}}; const context = {{ URL, Request, Response, Headers, AbortController, setTimeout, clearTimeout, console, @@ -79,9 +89,9 @@ const context = {{ return request; }}}}, caches: {{ - open: async () => cache, + open: async key => key === 'stackchain-dashboard-old' ? oldCache : cache, keys: async () => ['stackchain-dashboard-old', 'another-app-cache'], - delete: async key => {{ state.deleted.push(key); return true; }}, + delete: async key => {{ state.deleted.push(key); state.activationOrder.push('delete:' + key); return true; }}, match: async request => cache.match(request), }}, fetch: async (request, options = {{}}) => {{ @@ -152,10 +162,16 @@ async function dispatchPush(payload) {{ return json.loads(completed.stdout) +def test_offline_activation_migration_rolls_the_shell_cache(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v114" in source + + def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -164,7 +180,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -173,7 +189,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -181,14 +197,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -196,7 +212,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -204,7 +220,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -214,14 +230,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -230,21 +246,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/update-ownership.js'" in source @@ -914,7 +930,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/queue-today.js'" in source @@ -1056,6 +1072,30 @@ def test_activate_deletes_only_stale_stackchain_caches(): assert result["deleted"] == ["stackchain-dashboard-old"] +def test_offline_activation_migrates_cached_optional_feature_before_deleting_old_cache(): + result = run_worker_scenario( + """ + const asset = '/dashboard/feature-issue-capture-test.js'; + state.oldCachedAssets[asset] = 'previous cached feature'; + state.failedAdds = [asset]; + context.self.__testOptionalFeatures.push(asset); + await dispatch('activate'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["migrated"] == [ + "/dashboard/feature-issue-capture-test.js" + ] + assert result["individuallyAdded"] == [] + assert "/dashboard/feature-issue-capture-test.js" not in result["addAttempts"] + assert result["activationOrder"] == [ + "put:/dashboard/feature-issue-capture-test.js", + "delete:stackchain-dashboard-old", + ] + assert result["claimed"] is True + + def test_activate_warms_optional_features_without_blocking_siblings_or_claim(): result = run_worker_scenario( """ diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 10293e4..d348e25 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v113';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v114';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 4fa3387..86d7aca 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v113" in source + assert "stackchain-dashboard-shell-v114" in source assert "BASE + 'static/today-sync.js'" in source