diff --git a/static/index.html b/static/index.html
index 02ff26b..b0a2d9a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -2028,13 +2028,26 @@
opts.headers['Authorization'] = 'Bearer ' + AppState.authToken;
}
}
- // 15s timeout to prevent hanging lookups on slow/mobile networks
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 15000);
- opts.signal = controller.signal;
+ // 15s timeout via Promise.race instead of AbortController — the SW
+ // intercepts all API requests and AbortController signals don't properly
+ // propagate through the Service Worker layer, causing indefinite hangs
+ // on mobile browsers (Chrome/Safari known bug).
+ const TIMEOUT_MS = 10000;
+ const fetchPromise = fetch(url, opts);
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Request timed out — check your connection')), TIMEOUT_MS)
+ );
try {
- const res = await fetch(url, opts);
- clearTimeout(timeoutId);
+ let res;
+ try {
+ res = await Promise.race([fetchPromise, timeoutPromise]);
+ } catch (e) {
+ // timeoutPromise won — the fetch may still complete in the background
+ // but the user gets a clear timeout message instead of hanging.
+ if (e.message && e.message.includes('timed out')) throw e;
+ // If fetchPromise rejects first (network error), re-throw
+ throw e;
+ }
if (res.status === 204) return null;
const text = await res.text();
let data = null;
@@ -2046,13 +2059,9 @@
if (returnMeta) return { data, headers: res.headers };
return data;
} catch (e) {
- clearTimeout(timeoutId);
if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
throw new Error('Network error — check your connection');
}
- if (e.name === 'AbortError') {
- throw new Error('Request timed out — check your connection');
- }
throw e;
}
}
@@ -3014,11 +3023,22 @@
function stopScanning() {
scanningActive = false;
+ // Explicitly stop all camera tracks BEFORE resetting ZXing — on iOS
+ // Safari, just setting srcObject=null via codeReader.reset() doesn't
+ // release the hardware immediately, and an active camera stream can
+ // block fetch() on mobile browsers.
+ const video = document.getElementById('cameraVideo');
+ if (video) {
+ const stream = video.srcObject;
+ if (stream && typeof stream.getTracks === 'function') {
+ stream.getTracks().forEach(t => { try { t.stop(); } catch (_) {} });
+ }
+ video.srcObject = null;
+ }
if (codeReader) {
try { codeReader.reset(); } catch (e) { /* ignore */ }
codeReader = null;
}
- const video = document.getElementById('cameraVideo');
if (video) video.style.display = 'none';
const ph = document.getElementById('cameraPlaceholder');
if (ph) {
@@ -3065,36 +3085,49 @@
}
async function handleBarcode(machineId) {
- if (barcodeDebounce) return;
- barcodeDebounce = machineId;
- currentScannedMachineId = machineId;
-
- // Stop the camera scanner before the API call — on mobile browsers
- // an active camera stream can throttle concurrent fetch requests.
- stopScanning();
-
- setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
- document.getElementById('scanResult').style.display = 'none';
- document.getElementById('checkinCard').style.display = 'none';
-
try {
- const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
- // Asset exists — show result in-place with scan new button
- showToast(`${esc(asset.name)} — found!`, false);
- // Auto-checkin if GPS available (background, non-blocking)
- if (AppState.gpsLat != null) {
- doAutoCheckin(asset.id);
- }
- showScannedAsset(asset);
- } catch (e) {
- if (e.message === 'Asset not found' || e.message.includes('404')) {
- showNewAssetForm(machineId);
- } else {
- setScanStatus('Lookup error: ' + e.message, 'error');
- }
- }
+ if (barcodeDebounce) return;
+ barcodeDebounce = machineId;
+ currentScannedMachineId = machineId;
- setTimeout(() => { barcodeDebounce = null; }, 2000);
+ // Stop the camera scanner before the API call — on mobile browsers
+ // an active camera stream can throttle concurrent fetch requests.
+ stopScanning();
+ // Yield to the event loop so the browser fully releases camera hardware
+ // before we issue the fetch — critical on iOS Safari where an active
+ // stream can block concurrent network requests.
+ await new Promise(r => setTimeout(r, 200));
+
+ setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
+ const sr = document.getElementById('scanResult');
+ if (sr) sr.style.display = 'none';
+ const cc = document.getElementById('checkinCard');
+ if (cc) cc.style.display = 'none';
+
+ try {
+ const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
+ // Asset exists — show result in-place with scan new button
+ showToast(`${esc(asset.name)} — found!`, false);
+ // Auto-checkin if GPS available (background, non-blocking)
+ if (AppState.gpsLat != null) {
+ doAutoCheckin(asset.id);
+ }
+ showScannedAsset(asset);
+ } catch (e) {
+ if (e.message === 'Asset not found' || e.message.includes('404')) {
+ showNewAssetForm(machineId);
+ } else {
+ setScanStatus('Lookup error: ' + e.message, 'error');
+ }
+ }
+
+ setTimeout(() => { barcodeDebounce = null; }, 2000);
+ } catch (e) {
+ // Safety net — any unexpected error (DOM null, stopScanning fail, etc.)
+ // gets surfaced rather than being silently swallowed.
+ setScanStatus('Error: ' + (e.message || e), 'error');
+ setTimeout(() => { barcodeDebounce = null; }, 2000);
+ }
}
function showScannedAsset(asset) {
diff --git a/static/sw.js b/static/sw.js
index 6b15e44..05d599c 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -3,7 +3,7 @@
// Caches app shell + CDN deps. Network-first for API, cache fallback for static.
// ═══════════════════════════════════════════════════════════════════════════
-const CACHE_NAME = 'canteen-v10';
+const CACHE_NAME = 'canteen-v11';
// App shell — core resources needed to boot the PWA
const APP_SHELL = [
@@ -73,24 +73,12 @@ self.addEventListener('fetch', (event) => {
return;
}
- // API GET calls: network-first, cache fallback (offline reads)
- // API POST/PUT/DELETE: pass through — main thread handles offline queuing
+ // API requests: pass through — don't intercept at all.
+ // The SW cache doesn't help with real-time lookups, and SW interception
+ // combined with main-thread timeouts can cause indefinite hangs on mobile
+ // (AbortController signal doesn't propagate through SW fetch handler).
if (url.pathname.startsWith('/api/')) {
- if (event.request.method === 'GET') {
- event.respondWith(
- fetch(event.request)
- .then((response) => {
- if (response.ok) {
- const clone = response.clone();
- caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
- }
- return response;
- })
- .catch(() => caches.match(event.request))
- );
- }
- // Non-GET API calls: pass through — main thread queues offline writes
- return;
+ return; // Don't call event.respondWith — let the request go direct
}
// Static assets (local + CDN): network-first, cache fallback