Harden scanner startup and defer polyfill loading

- Fix waitForVideoReady race by re-checking after listeners and timing out
- Serialize iOS native scans with an in-flight guard and chained timeouts
- Lazy-load @undecaf/barcode-detector-polyfill only when scanning starts

Co-authored-by: Ned Halksworth <hello@nedhalksworth.com>
This commit is contained in:
Cursor Agent
2026-05-27 22:01:48 +00:00
parent 64584315e5
commit aa1bf1b21f
3 changed files with 76 additions and 24 deletions
+17 -5
View File
@@ -1,7 +1,12 @@
import { BarcodeDetectorPolyfill } from "@undecaf/barcode-detector-polyfill"; type BarcodeDetectorConstructor = {
new (options?: { formats?: string[] }): {
detect: (source: ImageBitmapSource) => Promise<Array<{ rawValue?: string; format?: string }>>;
};
getSupportedFormats?: () => Promise<string[]>;
};
type WindowWithBarcodeDetector = Window & { type WindowWithBarcodeDetector = Window & {
BarcodeDetector?: typeof BarcodeDetectorPolyfill; BarcodeDetector?: BarcodeDetectorConstructor;
}; };
let detectorReady: Promise<void> | null = null; let detectorReady: Promise<void> | null = null;
@@ -12,6 +17,11 @@ export function isAppleMobileDevice() {
return /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); return /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
} }
async function loadBarcodeDetectorPolyfill() {
const { BarcodeDetectorPolyfill } = await import("@undecaf/barcode-detector-polyfill");
return BarcodeDetectorPolyfill;
}
export function ensureBarcodeDetector() { export function ensureBarcodeDetector() {
if (detectorReady) return detectorReady; if (detectorReady) return detectorReady;
@@ -20,14 +30,16 @@ export function ensureBarcodeDetector() {
const shouldForcePolyfill = isAppleMobileDevice(); const shouldForcePolyfill = isAppleMobileDevice();
if (shouldForcePolyfill) { if (shouldForcePolyfill) {
globalWindow.BarcodeDetector = BarcodeDetectorPolyfill; globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
return; return;
} }
try { try {
await globalWindow.BarcodeDetector?.getSupportedFormats(); const getSupportedFormats = globalWindow.BarcodeDetector?.getSupportedFormats;
if (!getSupportedFormats) return;
await getSupportedFormats.call(globalWindow.BarcodeDetector);
} catch { } catch {
globalWindow.BarcodeDetector = BarcodeDetectorPolyfill; globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
} }
})(); })();
+59 -18
View File
@@ -64,6 +64,7 @@ const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
audio: false, audio: false,
}; };
const IOS_NATIVE_SCAN_INTERVAL_MS = 150; const IOS_NATIVE_SCAN_INTERVAL_MS = 150;
const VIDEO_READY_TIMEOUT_MS = 10_000;
export async function startBarcodeScanner( export async function startBarcodeScanner(
videoElement: HTMLVideoElement, videoElement: HTMLVideoElement,
@@ -125,7 +126,8 @@ function startNativeBarcodeScanner(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let stopped = false; let stopped = false;
let animationFrame = 0; let animationFrame = 0;
let scanInterval = 0; let scanTimeout = 0;
let scanning = false;
let stream: MediaStream | null = null; let stream: MediaStream | null = null;
async function start() { async function start() {
@@ -143,14 +145,15 @@ function startNativeBarcodeScanner(
const stop = () => { const stop = () => {
stopped = true; stopped = true;
window.cancelAnimationFrame(animationFrame); window.cancelAnimationFrame(animationFrame);
window.clearInterval(scanInterval); window.clearTimeout(scanTimeout);
stopVideoStream(videoElement); stopVideoStream(videoElement);
}; };
const scan = async () => { const scan = async () => {
if (stopped) return; if (stopped || scanning) return;
scanning = true;
try { try {
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0) { if (isVideoFrameReady(videoElement)) {
const barcodes = await detector.detect(videoElement); const barcodes = await detector.detect(videoElement);
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? "")); const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
if (barcode?.rawValue) { if (barcode?.rawValue) {
@@ -162,21 +165,29 @@ function startNativeBarcodeScanner(
} }
} catch { } catch {
// Keep scanning; transient frame errors are common on mobile Safari. // Keep scanning; transient frame errors are common on mobile Safari.
} finally {
scanning = false;
} }
}; };
const scheduleScan = () => { const scheduleNextScan = () => {
if (stopped) return; if (stopped) return;
void scan().finally(() => { if (isAppleMobileDevice()) {
if (!stopped) animationFrame = window.requestAnimationFrame(scheduleScan); scanTimeout = window.setTimeout(() => {
void scan().finally(() => {
if (!stopped) scheduleNextScan();
});
}, IOS_NATIVE_SCAN_INTERVAL_MS);
return;
}
animationFrame = window.requestAnimationFrame(() => {
void scan().finally(() => {
if (!stopped) scheduleNextScan();
});
}); });
}; };
if (isAppleMobileDevice()) { scheduleNextScan();
scanInterval = window.setInterval(() => void scan(), IOS_NATIVE_SCAN_INTERVAL_MS);
} else {
animationFrame = window.requestAnimationFrame(scheduleScan);
}
resolve({ mode: "native", stop }); resolve({ mode: "native", stop });
} catch (error) { } catch (error) {
@@ -278,28 +289,58 @@ function prepareVideoElement(videoElement: HTMLVideoElement, stream: MediaStream
videoElement.muted = true; videoElement.muted = true;
} }
function isVideoFrameReady(videoElement: HTMLVideoElement) {
return videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0;
}
async function waitForVideoReady(videoElement: HTMLVideoElement) { async function waitForVideoReady(videoElement: HTMLVideoElement) {
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0) { if (isVideoFrameReady(videoElement)) {
await playVideoElement(videoElement); await playVideoElement(videoElement);
return; return;
} }
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let settled = false;
const settle = (action: () => void) => {
if (settled) return;
settled = true;
cleanup();
action();
};
const tryReady = () => {
if (!isVideoFrameReady(videoElement)) return false;
settle(() => {
void playVideoElement(videoElement).then(resolve).catch(reject);
});
return true;
};
const onReady = () => { const onReady = () => {
cleanup(); tryReady();
void playVideoElement(videoElement).then(resolve).catch(reject);
}; };
const onError = () => { const onError = () => {
cleanup(); settle(() => reject(new Error("Camera preview failed to start.")));
reject(new Error("Camera preview failed to start."));
}; };
const cleanup = () => { const cleanup = () => {
window.clearTimeout(timeoutId);
videoElement.removeEventListener("loadedmetadata", onReady); videoElement.removeEventListener("loadedmetadata", onReady);
videoElement.removeEventListener("loadeddata", onReady);
videoElement.removeEventListener("error", onError); videoElement.removeEventListener("error", onError);
}; };
videoElement.addEventListener("loadedmetadata", onReady, { once: true }); const timeoutId = window.setTimeout(() => {
settle(() => reject(new Error("Camera preview timed out.")));
}, VIDEO_READY_TIMEOUT_MS);
videoElement.addEventListener("loadedmetadata", onReady);
videoElement.addEventListener("loadeddata", onReady);
videoElement.addEventListener("error", onError, { once: true }); videoElement.addEventListener("error", onError, { once: true });
tryReady();
}); });
} }
-1
View File
@@ -13,7 +13,6 @@ export default defineConfig(({ command }) => ({
charts: ["recharts"], charts: ["recharts"],
motion: ["framer-motion"], motion: ["framer-motion"],
icons: ["lucide-react"], icons: ["lucide-react"],
barcode: ["@undecaf/barcode-detector-polyfill"],
}, },
}, },
}, },