Open cached dashboard when mobile navigation stalls #354
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user