Make PWA updates resilient with progressive feature caching #620
|
|
@ -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)));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user