fix: replace AbortController with Promise.race to avoid SW hanging fetch bug
Root cause: AbortController signals don't properly propagate through Service Worker fetch handlers (known Chrome/Safari bug), causing the fetch to hang indefinitely on mobile browsers instead of timing out. Fix: replace AbortController-based timeout in api() with Promise.race against a simple setTimeout. Also: - Stop all MediaStream tracks explicitly in stopScanning() - Add 200ms yield point before API call for hardware release - Bypass SW for API requests entirely (no caching needed for lookups) - Bump SW cache to v11 - Add outer try/catch safety net in handleBarcode
This commit is contained in:
+72
-39
@@ -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) {
|
||||
|
||||
+6
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user