From 065275b4efa5c901acc03aa824c38b39c32a8771 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 04:24:18 +0000 Subject: [PATCH] feat: progressively cache optional PWA features (Closes #619) --- frontend/service-worker.js | 20 ++++++++++++--- src/frontend_bundle.py | 11 +++++++- tests/test_frontend_bundle.py | 12 +++++++++ tests/test_service_worker.py | 47 +++++++++++++++++++++++++++++++++-- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f85ac11..84f9193 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -73,6 +73,8 @@ const SHELL = [ BASE + 'static/push-notifications.js', BASE + 'static/background-issue-sync.js', ]; +const OPTIONAL_FEATURES = [ +]; async function fetchNavigation(request) { const controller = new AbortController(); @@ -262,10 +264,16 @@ self.addEventListener('install', event => { }); self.addEventListener('activate', event => { - event.waitUntil(caches.keys().then(keys => Promise.all( - keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE) - .map(key => caches.delete(key)) - )).then(() => self.clients.claim())); + 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 cache = await caches.open(CACHE); + await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset))); + await self.clients.claim(); + })()); }); self.addEventListener('sync', event => { @@ -433,5 +441,9 @@ self.addEventListener('fetch', event => { } if (url.origin === self.location.origin && SHELL.includes(url.pathname)) { event.respondWith(caches.match(request).then(cached => cached || fetch(request))); + return; + } + if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) { + event.respondWith(caches.match(request).then(cached => cached || fetch(request))); } }); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index f4a4c22..9fa5eb1 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -114,11 +114,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: for source in sources: if source != WORKER_RUNTIME_SOURCE: worker = worker.replace(f" BASE + '{source}',\n", "") + eager_feature = feature_bundles["today-timer"] + optional_features = { + name: bundle for name, bundle in feature_bundles.items() if name != "today-timer" + } worker = worker.replace( " BASE + 'static/dashboard.css',\n", " BASE + 'static/dashboard.css',\n" + f" BASE + '{core.runtime_name}',\n" - + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()), + + f" BASE + '{eager_feature.runtime_name}',\n", + ) + worker = worker.replace( + "const OPTIONAL_FEATURES = [\n", + "const OPTIONAL_FEATURES = [\n" + + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()), ) worker = CACHE_DECLARATION.sub( "const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1 diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 75b6c2d..f2eb2d6 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -77,6 +77,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source + shell_block, optional_block = first.service_worker_source.split( + "const OPTIONAL_FEATURES = [", 1 + ) + optional_block = optional_block.split("];", 1)[0] + assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block + for name, bundle in first.feature_bundles.items(): + if name == "today-timer": + assert f"BASE + '{bundle.runtime_name}'" not in optional_block + else: + assert f"BASE + '{bundle.runtime_name}'" not in shell_block + assert f"BASE + '{bundle.runtime_name}'" in optional_block + changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) capture_source = changed_frontend / "create-issue-sheet.js" diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 7ae4df5..5f0469c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], 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: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], 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', @@ -24,6 +24,10 @@ storedResponses.set( ); const cache = {{ addAll: async urls => {{ state.added = urls; }}, + add: async url => {{ + if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable'); + state.individuallyAdded.push(url); + }}, match: async request => {{ const key = String(request.url || request); if (storedResponses.has(key)) return storedResponses.get(key).clone(); @@ -89,7 +93,7 @@ const context = {{ }}, }}; vm.createContext(context); -vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context); +vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context); async function dispatch(name, request) {{ let pending; let response; @@ -746,6 +750,45 @@ def test_activate_deletes_only_stale_stackchain_caches(): assert result["deleted"] == ["stackchain-dashboard-old"] +def test_activate_warms_optional_features_without_blocking_siblings_or_claim(): + result = run_worker_scenario( + """ + state.failedAdds = ['/dashboard/feature-security-center-test.js']; + context.self.__testOptionalFeatures.push( + '/dashboard/feature-issue-capture-test.js', + '/dashboard/feature-security-center-test.js', + '/dashboard/feature-pull-workflow-test.js', + ); + await dispatch('activate'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["claimed"] is True + assert result["individuallyAdded"] == [ + "/dashboard/feature-issue-capture-test.js", + "/dashboard/feature-pull-workflow-test.js", + ] + + +def test_warmed_optional_feature_is_served_from_cache_while_offline(): + result = run_worker_scenario( + """ + context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js'); + state.cachedBody = 'cached feature'; + state.failFetch = true; + const response = await dispatch('fetch', { + method: 'GET', mode: 'cors', + url: 'https://forge.example/dashboard/feature-issue-capture-test.js', + }); + process.stdout.write(JSON.stringify({ body: await response.text(), state })); +""" + ) + + assert result["body"] == "cached feature" + assert result["state"]["fetches"] == [] + + def test_offline_navigation_returns_cached_shell_for_share_target_url(): result = run_worker_scenario( """ -- 2.43.0