Open cached dashboard when mobile navigation stalls #354

Merged
timmy merged 1 commits from timmy/353-stalled-navigation-deadline into main 2026-08-08 23:56:16 +00:00
5 changed files with 124 additions and 13 deletions

View File

@ -42,7 +42,10 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued.
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries

View File

@ -1,7 +1,8 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v42';
const CACHE = 'stackchain-dashboard-shell-v43';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@ -44,6 +45,25 @@ const SHELL = [
BASE + 'static/background-issue-sync.js',
];
async function fetchNavigation(request) {
const controller = new AbortController();
let timeout;
const deadline = new Promise((resolve, reject) => {
timeout = setTimeout(() => {
controller.abort();
reject(new Error('Dashboard navigation timed out.'));
}, NAVIGATION_TIMEOUT_MS);
});
try {
return await Promise.race([
fetch(request, { signal: controller.signal }),
deadline,
]);
} finally {
clearTimeout(timeout);
}
}
async function sessionCsrf(signal) {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
@ -182,7 +202,7 @@ self.addEventListener('fetch', event => {
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).then(async response => {
fetchNavigation(request).then(async response => {
const cache = await caches.open(CACHE);
const responseUrl = new URL(response.url || request.url);
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
@ -195,7 +215,11 @@ self.addEventListener('fetch', event => {
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
return cache.match(BASE);
const cached = await cache.match(BASE);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
})
);
return;

View File

@ -137,4 +137,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-v42" in worker
assert "stackchain-dashboard-shell-v43" in worker

View File

@ -35,4 +35,4 @@ 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-v42" in worker
assert "stackchain-dashboard-shell-v43" in worker

View File

@ -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: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
@ -21,9 +21,10 @@ const cache = {{
}};
const context = {{
URL, Request, Response, Headers, AbortController,
console,
setTimeout, clearTimeout, console,
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@ -45,9 +46,19 @@ const context = {{
delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request),
}},
fetch: async request => {{
fetch: async (request, options = {{}}) => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
if (state.stallFetch) return new Promise((resolve, reject) => {{
options.signal?.addEventListener('abort', () => {{
state.fetchAborted = true;
reject(new Error('aborted'));
}}, {{ once: true }});
}});
if (state.lateFetch) return new Promise(resolve => {{
options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }});
setTimeout(() => resolve(new Response('late network')), 50);
}});
const response = new Response('network', {{ status: state.fetchStatus }});
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
return response;
@ -94,10 +105,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v42" in source
assert "stackchain-dashboard-shell-v43" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -106,14 +117,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v42" in source
assert "stackchain-dashboard-shell-v43" 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-v42" in source
assert "stackchain-dashboard-shell-v43" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -360,6 +371,79 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
state.stallFetch = true;
state.cachedBody = 'cached dashboard';
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 80)),
]);
process.stdout.write(JSON.stringify({
timedOut: response === null,
body: response ? await response.text() : null,
state,
}));
"""
)
assert result["timedOut"] is False
assert result["body"] == "cached dashboard"
assert result["state"]["fetchAborted"] is True
assert result["state"]["puts"] == []
def test_stalled_navigation_without_cached_shell_returns_deterministic_504():
result = run_worker_scenario(
"""
state.stallFetch = true;
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 80)),
]);
process.stdout.write(JSON.stringify({
status: response?.status || null,
body: response ? await response.text() : null,
contentType: response?.headers.get('Content-Type') || null,
state,
}));
"""
)
assert result["status"] == 504
assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again."
assert result["contentType"] == "text/plain; charset=utf-8"
assert result["state"]["fetchAborted"] is True
def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write():
result = run_worker_scenario(
"""
state.lateFetch = true;
state.cachedBody = 'cached dashboard';
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 35)),
]);
const body = response ? await response.text() : null;
await new Promise(resolve => setTimeout(resolve, 60));
process.stdout.write(JSON.stringify({timedOut: response === null, body, state}));
"""
)
assert result["timedOut"] is False
assert result["body"] == "cached dashboard"
assert result["state"]["fetchAborted"] is True
assert result["state"]["puts"] == []
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
result = run_worker_scenario(
"""