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:
@@ -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();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
+57
-16
@@ -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;
|
||||||
|
if (isAppleMobileDevice()) {
|
||||||
|
scanTimeout = window.setTimeout(() => {
|
||||||
void scan().finally(() => {
|
void scan().finally(() => {
|
||||||
if (!stopped) animationFrame = window.requestAnimationFrame(scheduleScan);
|
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) => {
|
||||||
const onReady = () => {
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (action: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
cleanup();
|
cleanup();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryReady = () => {
|
||||||
|
if (!isVideoFrameReady(videoElement)) return false;
|
||||||
|
settle(() => {
|
||||||
void playVideoElement(videoElement).then(resolve).catch(reject);
|
void playVideoElement(videoElement).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onReady = () => {
|
||||||
|
tryReady();
|
||||||
|
};
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user