From f10020705c2a9662d0b2220a114e3d0264dbcc48 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 5 Oct 2026 08:38:37 +0200 Subject: [PATCH] fix(scan): prevent camera support render loop --- src/app/scan/page.test.tsx | 61 ++++++++++++++++++++++++++++++++++++++ src/app/scan/page.tsx | 35 ++++++++++++++++------ 2 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 src/app/scan/page.test.tsx 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("");