fix(scan): prevent camera support render loop
This commit is contained in:
parent
c0e6ad2099
commit
f10020705c
2 changed files with 87 additions and 9 deletions
61
src/app/scan/page.test.tsx
Normal file
61
src/app/scan/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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("");
|
||||
|
|
|
|||
Loading…
Reference in a new issue