fix(scan): prevent camera support render loop

This commit is contained in:
Dave 2026-10-05 08:38:37 +02:00
parent c0e6ad2099
commit f10020705c
2 changed files with 87 additions and 9 deletions

View file

@ -0,0 +1,61 @@
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ScanPage from "./page";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ScanPage", () => {
it("renders camera controls without a render loop and remains usable after typing", () => {
vi.stubGlobal("isSecureContext", true);
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn() },
});
render(<StrictMode><ScanPage /></StrictMode>);
expect(screen.getByRole("button", { name: "Start camera" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Go" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Or type the delivery note number"), {
target: { value: "dn26100401" },
});
expect(screen.getByLabelText("Or type the delivery note number")).toHaveValue("DN26100401");
expect(screen.getByRole("button", { name: "Go" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Start camera" })).toBeEnabled();
});
it("keeps manual entry available on an insecure address without a camera", () => {
vi.stubGlobal("isSecureContext", false);
vi.stubGlobal("navigator", {});
render(<ScanPage />);
expect(screen.getByTestId("scan-insecure-warning")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Start camera" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Or type the delivery note number"), {
target: { value: "DN26100401" },
});
expect(screen.getByRole("button", { name: "Go" })).toBeEnabled();
});
it("hydrates the server page and enables the browser's camera controls", () => {
vi.stubGlobal("isSecureContext", true);
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn() },
});
const container = document.createElement("div");
container.innerHTML = renderToString(<ScanPage />);
document.body.appendChild(container);
expect(container.querySelector("button")).toBeDisabled();
render(<ScanPage />, { container, hydrate: true });
expect(screen.getByRole("button", { name: "Start camera" })).toBeEnabled();
expect(screen.getByLabelText("Or type the delivery note number")).toBeInTheDocument();
});
});

View file

@ -84,6 +84,25 @@ const STATUS_COPY: Record<string, string> = {
cancelled: "Cancelled",
};
let supportSnapshot: ReturnType<typeof detectScannerSupport> | null = null;
function getSupportSnapshot() {
const next = detectScannerSupport();
if (
!supportSnapshot ||
next.kind !== supportSnapshot.kind ||
next.camera !== supportSnapshot.camera ||
next.secureContext !== supportSnapshot.secureContext
) {
supportSnapshot = next;
}
return supportSnapshot;
}
// Capabilities have no change event; React rechecks them on render.
const subscribeToSupport = () => () => {};
const getServerSupportSnapshot = () => null;
export default function ScanPage() {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@ -96,17 +115,15 @@ export default function ScanPage() {
const wakeRef = React.useRef<WakeLockSentinelLike | null>(null);
/**
* Capability snapshot. `useSyncExternalStore` with a null server
* snapshot rather than `useState` + `useEffect`: the value only
* exists in a browser (it reads `navigator` and `window`), so the
* first client render has to MATCH the server's null and then
* re-render with the real answer. Setting state inside an effect to
* get there is the React 19 `set-state-in-effect` anti-pattern.
* Match the server's null snapshot during hydration, then read the
* browser capabilities. Unchanged capabilities must return the same
* object: a fresh object on every read makes useSyncExternalStore
* keep re-rendering until React throws "Maximum update depth exceeded".
*/
const support = React.useSyncExternalStore(
() => () => {},
() => detectScannerSupport(),
() => null,
subscribeToSupport,
getSupportSnapshot,
getServerSupportSnapshot,
);
const [cameraOn, setCameraOn] = React.useState(false);
const [manual, setManual] = React.useState("");