diff --git a/src/app/scan/page.test.tsx b/src/app/scan/page.test.tsx
new file mode 100644
index 0000000..383939e
--- /dev/null
+++ b/src/app/scan/page.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+ document.body.appendChild(container);
+ expect(container.querySelector("button")).toBeDisabled();
+
+ render(, { container, hydrate: true });
+
+ expect(screen.getByRole("button", { name: "Start camera" })).toBeEnabled();
+ expect(screen.getByLabelText("Or type the delivery note number")).toBeInTheDocument();
+ });
+});
diff --git a/src/app/scan/page.tsx b/src/app/scan/page.tsx
index c940922..6d85013 100644
--- a/src/app/scan/page.tsx
+++ b/src/app/scan/page.tsx
@@ -84,6 +84,25 @@ const STATUS_COPY: Record = {
cancelled: "Cancelled",
};
+let supportSnapshot: ReturnType | 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(null);
const canvasRef = React.useRef(null);
@@ -96,17 +115,15 @@ export default function ScanPage() {
const wakeRef = React.useRef(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("");