Browse Source

Camera views open one stream, not one per discarded render (#3431)

- React sets an img's src while rendering, so every render it threw
  away opened an MJPEG stream on an img that never reached the page;
  a camera window mounting during page load left about twenty open
- useStreamImgRef now takes the stream URL and sets it once the img is
  in the document, only on attach or a URL change
- Camera window, camera page, camera wall tile and both stream
  overlays render their stream img without a src prop
- Tests fail on any stream src set on a detached img, and on a render
  thrown away beside a suspending sibling
maziggy 9 hours ago
parent
commit
f29b735bbe
39 changed files with 320 additions and 32 deletions
  1. 171 0
      frontend/src/__tests__/components/streamImgSrcAtCommit3431.test.tsx
  2. 22 0
      frontend/src/__tests__/pages/CameraPage.test.tsx
  3. 21 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  4. 48 0
      frontend/src/__tests__/streamImgSpy.ts
  5. 2 2
      frontend/src/components/CameraTile.tsx
  6. 2 2
      frontend/src/components/EmbeddedCameraViewer.tsx
  7. 7 4
      frontend/src/components/UpdatedStreamOverlay.tsx
  8. 26 6
      frontend/src/hooks/useStreamImgRef.ts
  9. 2 2
      frontend/src/pages/CameraPage.tsx
  10. 13 9
      frontend/src/pages/StreamOverlayPage.tsx
  11. 0 0
      static/assets/ArchivesPage-1pXnc75v.js
  12. 0 0
      static/assets/CamWallPage-BzrTfqQP.js
  13. 0 0
      static/assets/CameraPage-Bjy2wV8L.js
  14. 0 0
      static/assets/CameraPage-C8X4lWq4.js
  15. 0 0
      static/assets/CameraWall-5osj8wUL.js
  16. 0 0
      static/assets/CameraWall-D46f3IC6.js
  17. 0 0
      static/assets/EmbeddedCameraViewer-B6F8oslL.js
  18. 0 0
      static/assets/EmbeddedCameraViewer-DBmYe-Ss.js
  19. 0 0
      static/assets/FileManagerPage-B0rZDPsu.js
  20. 0 0
      static/assets/InventoryPage-BCQkz_16.js
  21. 0 0
      static/assets/Layout-DaLZ1Hi3.js
  22. 0 0
      static/assets/ModelSourcesPage-V6HVGyPl.js
  23. 0 0
      static/assets/PrintersPage-A4Lovfbi.js
  24. 0 0
      static/assets/SettingsPage-Bd-gMA0a.js
  25. 0 0
      static/assets/SliceModal-DZ_b0INA.js
  26. 0 0
      static/assets/SpoolBuddyInventoryPage-BDOcOb8a.js
  27. 1 1
      static/assets/SpoolBuddySettingsPage-Ce4jxGrd.js
  28. 0 0
      static/assets/StreamOverlayPage-BTTnIRH0.js
  29. 0 0
      static/assets/StreamOverlayPage-CmvEYNWa.js
  30. 0 0
      static/assets/index-B8I0vmF5.js
  31. 0 0
      static/assets/locationSensorDefaults-TX3M68AR.js
  32. 0 1
      static/assets/plateCheck-DziO5lKr.js
  33. 0 0
      static/assets/spoolCost-UYPo-BiE.js
  34. 1 0
      static/assets/useCameraViewerId-BG_jtrZ0.js
  35. 0 1
      static/assets/useCameraViewerId-Do_Hn98U.js
  36. 0 0
      static/assets/usePageFileDrop-BlkyJnHC.js
  37. 1 0
      static/assets/useSidewaysBox-CCRxxEAM.js
  38. 0 1
      static/assets/useSidewaysBox-M-7Ir3rk.js
  39. 3 3
      static/index.html

+ 171 - 0
frontend/src/__tests__/components/streamImgSrcAtCommit3431.test.tsx

@@ -0,0 +1,171 @@
+import { StrictMode, Suspense, useRef } from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, render as rawRender, screen } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { spyOnStreamSrc } from '../streamImgSpy';
+import { useStreamImgRef } from '../../hooks/useStreamImgRef';
+import { EmbeddedCameraViewer } from '../../components/EmbeddedCameraViewer';
+import { CameraTile } from '../../components/CameraTile';
+import { UpdatedStreamOverlay } from '../../components/UpdatedStreamOverlay';
+
+// A camera overlay that mounted while the page was still loading opened about
+// twenty MJPEG streams: React set each img's src while rendering, threw most
+// of those renders away, and the browser kept every one of their requests open
+// on an img that never reached the page (#3431). A stream src now only ever
+// reaches an img that is in the document.
+
+async function settle() {
+  await act(async () => {
+    await Promise.resolve();
+  });
+}
+
+/** Suspends its first render, so React throws away the render beside it. */
+function suspendOnce() {
+  let done = false;
+  let resolve!: () => void;
+  const promise = new Promise<void>(r => {
+    resolve = () => {
+      done = true;
+      r();
+    };
+  });
+  function Gate() {
+    if (!done) throw promise;
+    return null;
+  }
+  return { Gate, release: () => act(async () => resolve()) };
+}
+
+let spy: ReturnType<typeof spyOnStreamSrc>;
+
+beforeEach(() => {
+  spy = spyOnStreamSrc();
+  localStorage.clear();
+  server.use(
+    http.post('/api/v1/printers/:id/camera/stop', () => HttpResponse.json({ success: true })),
+    http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })),
+  );
+});
+
+afterEach(() => {
+  spy.restore();
+  vi.restoreAllMocks();
+});
+
+describe('useStreamImgRef', () => {
+  function Stream({ src, onRender }: { src: string | undefined; onRender?: () => void }) {
+    const imgRef = useRef<HTMLImageElement | null>(null);
+    const attach = useStreamImgRef(imgRef, src);
+    onRender?.();
+    return <img ref={attach} alt="s" />;
+  }
+
+  it('gives the img its src once it is in the page', async () => {
+    rawRender(<Stream src="/api/v1/printers/1/camera/stream?t=1" />);
+
+    expect(screen.getByAltText('s').getAttribute('src')).toBe('/api/v1/printers/1/camera/stream?t=1');
+    expect(spy.detached()).toEqual([]);
+  });
+
+  it('follows a new URL and removes it when there is none', async () => {
+    const view = rawRender(<Stream src="/api/v1/printers/1/camera/stream?t=1" />);
+    view.rerender(<Stream src="/api/v1/printers/1/camera/stream?t=2" />);
+    expect(screen.getByAltText('s').getAttribute('src')).toBe('/api/v1/printers/1/camera/stream?t=2');
+
+    view.rerender(<Stream src={undefined} />);
+    expect(screen.getByAltText('s').hasAttribute('src')).toBe(false);
+    view.rerender(<Stream src="" />);
+    expect(screen.getByAltText('s').hasAttribute('src')).toBe(false);
+  });
+
+  it('does not reopen a stream the component closed itself on an unrelated re-render', async () => {
+    const view = rawRender(<Stream src="/api/v1/printers/1/camera/stream?t=1" />);
+    const img = screen.getByAltText('s');
+    img.setAttribute('src', ''); // what the viewers' reconnect and restart code does
+
+    view.rerender(<Stream src="/api/v1/printers/1/camera/stream?t=1" onRender={() => {}} />);
+
+    expect(img.getAttribute('src')).toBe('');
+  });
+
+  it('sets the src once when React attaches the img twice (StrictMode)', async () => {
+    // Setting the same src again restarts the request: a second stream.
+    rawRender(
+      <StrictMode>
+        <Stream src="/api/v1/printers/1/camera/stream?t=1" />
+      </StrictMode>,
+    );
+
+    expect(spy.records).toHaveLength(1);
+    expect(screen.getByAltText('s').getAttribute('src')).toBe('/api/v1/printers/1/camera/stream?t=1');
+  });
+
+  it('opens nothing for a render React throws away', async () => {
+    const { Gate, release } = suspendOnce();
+    rawRender(
+      <Suspense fallback={null}>
+        <Stream src="/api/v1/printers/1/camera/stream?t=1" />
+        <Gate />
+      </Suspense>,
+    );
+    await settle();
+    await release();
+
+    expect(spy.leaked()).toEqual([]);
+    expect(screen.getByAltText('s').getAttribute('src')).toContain('/camera/stream');
+  });
+});
+
+describe('every camera view sets the stream src at commit', () => {
+  it('the floating camera overlay, also beside a render React throws away', async () => {
+    const { Gate, release } = suspendOnce();
+    render(
+      <Suspense fallback={null}>
+        <EmbeddedCameraViewer printerId={7} printerName="H2C" onClose={() => {}} />
+        <Gate />
+      </Suspense>,
+    );
+    await settle();
+    await release();
+    await settle();
+
+    expect(screen.getByAltText('Camera stream').getAttribute('src')).toContain('/api/v1/printers/7/camera/stream');
+    expect(spy.detached()).toEqual([]);
+    expect(spy.leaked()).toEqual([]);
+  });
+
+  it('the camera wall tile', async () => {
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
+    render(<CameraTile printerId={42} printerName="X1C" mode="live" snapshotIntervalMs={5000} connected />);
+    await settle();
+
+    expect(screen.getByAltText('X1C').getAttribute('src')).toContain('/api/v1/printers/42/camera/stream');
+    expect(spy.detached()).toEqual([]);
+  });
+
+  it('the stream overlay artwork', async () => {
+    render(
+      <UpdatedStreamOverlay
+        size="medium"
+        camera={{ url: '/api/v1/printers/3/camera/stream?t=1', rotation: 0, onError: () => {} }}
+        name="Workshop"
+        model={null}
+        filename={null}
+        status={null}
+        state={null}
+        progress={null}
+        layers={null}
+        remaining={null}
+        eta={null}
+        temperatures={[]}
+      />,
+    );
+    await settle();
+
+    expect(screen.getByAltText('Camera stream').getAttribute('src')).toContain('/api/v1/printers/3/camera/stream');
+    expect(spy.detached()).toEqual([]);
+  });
+});

+ 22 - 0
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -16,6 +16,7 @@ import { AuthProvider } from '../../contexts/AuthContext';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 import i18n from '../../i18n';
 import i18n from '../../i18n';
 import { listenForCameraPopIn } from '../../utils/camera';
 import { listenForCameraPopIn } from '../../utils/camera';
+import { spyOnStreamSrc } from '../streamImgSpy';
 
 
 // Mock navigator.sendBeacon which isn't available in jsdom
 // Mock navigator.sendBeacon which isn't available in jsdom
 vi.stubGlobal('navigator', {
 vi.stubGlobal('navigator', {
@@ -198,6 +199,27 @@ describe('CameraPage', () => {
       return document.querySelector('img') as HTMLImageElement;
       return document.querySelector('img') as HTMLImageElement;
     }
     }
 
 
+    it('gives the stream src only to an img already on the page (#3431)', async () => {
+      // Set while rendering, a src opens a stream for every render React throws
+      // away, on an img nothing can close.
+      const spy = spyOnStreamSrc();
+      try {
+        renderCameraPage(1);
+        const first = await streamImg();
+        // A restart mounts a new img (a new key), which is where a src prop
+        // would land before the img is on the page.
+        fireEvent.load(first);
+        await waitFor(() => expect(screen.getByTitle('Restart stream')).not.toBeDisabled());
+        fireEvent.click(screen.getByTitle('Restart stream'));
+        await waitFor(() => expect(document.querySelector('img')).not.toBe(first));
+        await streamImg();
+        expect(spy.records.length).toBeGreaterThan(1);
+        expect(spy.detached()).toEqual([]);
+      } finally {
+        spy.restore();
+      }
+    });
+
     async function snapshotImg() {
     async function snapshotImg() {
       const stream = await streamImg();
       const stream = await streamImg();
       // The mode buttons stay disabled until the stream shows a frame.
       // The mode buttons stay disabled until the stream shows a frame.

+ 21 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -13,6 +13,7 @@ import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { AuthProvider } from '../../contexts/AuthContext';
 import { AuthProvider } from '../../contexts/AuthContext';
 import { setStreamToken } from '../../api/client';
 import { setStreamToken } from '../../api/client';
+import { spyOnStreamSrc } from '../streamImgSpy';
 
 
 const mockPrinter = {
 const mockPrinter = {
   id: 1,
   id: 1,
@@ -580,6 +581,26 @@ describe('StreamOverlayPage', () => {
     });
     });
   });
   });
 
 
+  describe('stream src at commit (#3431)', () => {
+    // Set while rendering, a src opens a stream for every render React throws
+    // away, on an img nothing can close.
+    it.each([
+      ['classic', ''],
+      ['updated artwork', '?artwork=2'],
+    ])('the %s overlay gives it only to an img already on the page', async (_name, query) => {
+      const spy = spyOnStreamSrc();
+      try {
+        renderOverlayPage(1, query);
+        await waitFor(() => {
+          expect(screen.getByAltText('Camera stream').getAttribute('src')).toContain('/camera/stream');
+        });
+        expect(spy.detached()).toEqual([]);
+      } finally {
+        spy.restore();
+      }
+    });
+  });
+
   describe('FPS configuration', () => {
   describe('FPS configuration', () => {
     it('uses default FPS of 15 when not specified', async () => {
     it('uses default FPS of 15 when not specified', async () => {
       renderOverlayPage(1);
       renderOverlayPage(1);

+ 48 - 0
frontend/src/__tests__/streamImgSpy.ts

@@ -0,0 +1,48 @@
+/**
+ * Records every camera stream src given to an <img>, and whether that img was
+ * in the document at the moment (#3431).
+ *
+ * React sets props while it renders, before it commits anything, so a src
+ * passed as a prop lands on an img that is not on the page yet -- and on the
+ * img of every render React later throws away, which the browser opens all
+ * the same. A stream src must only ever be set on an attached img.
+ */
+export interface StreamSrcRecord {
+  img: HTMLImageElement;
+  src: string;
+  attached: boolean;
+}
+
+export function spyOnStreamSrc(match = /\/camera\/(stream|snapshot)/) {
+  const records: StreamSrcRecord[] = [];
+  const proto = HTMLImageElement.prototype;
+  const descriptor = Object.getOwnPropertyDescriptor(proto, 'src')!;
+  const setAttribute = Element.prototype.setAttribute;
+  const note = (img: HTMLImageElement, value: unknown) => {
+    const src = String(value);
+    if (match.test(src)) records.push({ img, src, attached: img.isConnected });
+  };
+  Object.defineProperty(proto, 'src', {
+    ...descriptor,
+    set(this: HTMLImageElement, value: string) {
+      note(this, value);
+      descriptor.set!.call(this, value);
+    },
+  });
+  Element.prototype.setAttribute = function (this: Element, name: string, value: string) {
+    if (this instanceof HTMLImageElement && name === 'src') note(this, value);
+    return setAttribute.call(this, name, value);
+  };
+  return {
+    records,
+    /** Stream srcs set on an img that was not in the document. */
+    detached: () => records.filter(r => !r.attached),
+    /** Imgs outside the document that still hold a stream src: open connections. */
+    leaked: () =>
+      [...new Set(records.map(r => r.img))].filter(img => !img.isConnected && match.test(img.getAttribute('src') ?? '')),
+    restore: () => {
+      Object.defineProperty(proto, 'src', descriptor);
+      Element.prototype.setAttribute = setAttribute;
+    },
+  };
+}

+ 2 - 2
frontend/src/components/CameraTile.tsx

@@ -99,7 +99,6 @@ export function CameraTile({
   const imgRef = useRef<HTMLImageElement | null>(null);
   const imgRef = useRef<HTMLImageElement | null>(null);
   // On its stream and its /camera/stop: the stop ends this tile's stream only.
   // On its stream and its /camera/stop: the stop ends this tile's stream only.
   const viewerId = useCameraViewerId();
   const viewerId = useCameraViewerId();
-  const attachImg = useStreamImgRef(imgRef);
   // 'renewed': the current img is a renewal not yet showing a frame; 'retried':
   // 'renewed': the current img is a renewal not yet showing a frame; 'retried':
   // it is the one retry of a renewal that failed.
   // it is the one retry of a renewal that failed.
   const renewalRef = useRef<'none' | 'renewed' | 'retried'>('none');
   const renewalRef = useRef<'none' | 'renewed' | 'retried'>('none');
@@ -230,6 +229,8 @@ export function CameraTile({
   const snapshotUrl = withStreamToken(
   const snapshotUrl = withStreamToken(
     `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
     `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
   );
   );
+  // The img gets its src from here, at commit, never from a prop (#3431).
+  const attachImg = useStreamImgRef(imgRef, reopening ? undefined : mode === 'live' ? liveUrl : snapshotUrl);
 
 
   // A kiosk wall passes no onClick — there is no pointer at a TV, and the page
   // A kiosk wall passes no onClick — there is no pointer at a TV, and the page
   // is authenticated by a token that cannot open the single-camera view. Render
   // is authenticated by a token that cannot open the single-camera view. Render
@@ -292,7 +293,6 @@ export function CameraTile({
         <img
         <img
           ref={attachImg}
           ref={attachImg}
           key={`${mode}-${bust}`}
           key={`${mode}-${bust}`}
-          src={reopening ? undefined : mode === 'live' ? liveUrl : snapshotUrl}
           alt={printerName}
           alt={printerName}
           draggable={false}
           draggable={false}
           loading="lazy"
           loading="lazy"

+ 2 - 2
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -120,7 +120,6 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const imgRef = useRef<HTMLImageElement | null>(null);
   const imgRef = useRef<HTMLImageElement | null>(null);
   // On its stream and its /camera/stop: the stop ends this viewer's stream only.
   // On its stream and its /camera/stop: the stop ends this viewer's stream only.
   const viewerId = useCameraViewerId();
   const viewerId = useCameraViewerId();
-  const attachImg = useStreamImgRef(imgRef);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -586,6 +585,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   }, [isDragging, isResizing, dragOffset, minY]);
   }, [isDragging, isResizing, dragOffset, minY]);
 
 
   const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}&viewer=${viewerId}`);
   const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}&viewer=${viewerId}`);
+  // The img gets its src from here, at commit, never from a prop (#3431).
+  const attachImg = useStreamImgRef(imgRef, streamUrl);
 
 
   return (
   return (
     <div
     <div
@@ -747,7 +748,6 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           <img
           <img
             ref={attachImg}
             ref={attachImg}
             key={imageKey}
             key={imageKey}
-            src={streamUrl}
             alt="Camera stream"
             alt="Camera stream"
             className="max-w-full max-h-full object-contain select-none"
             className="max-w-full max-h-full object-contain select-none"
             style={{
             style={{

+ 7 - 4
frontend/src/components/UpdatedStreamOverlay.tsx

@@ -1,9 +1,10 @@
-import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref, type SyntheticEvent } from 'react';
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type MutableRefObject, type ReactNode, type SyntheticEvent } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import './UpdatedStreamOverlay.css';
 import './UpdatedStreamOverlay.css';
 import { overlayProgressTextStyle } from '../utils/overlayBranding';
 import { overlayProgressTextStyle } from '../utils/overlayBranding';
 import { useSidewaysBox } from '../hooks/useSidewaysBox';
 import { useSidewaysBox } from '../hooks/useSidewaysBox';
+import { useStreamImgRef } from '../hooks/useStreamImgRef';
 
 
 import type { OverlayLayout } from '../utils/overlayLayout';
 import type { OverlayLayout } from '../utils/overlayLayout';
 
 
@@ -18,7 +19,7 @@ interface UpdatedStreamOverlayProps {
     url: string | undefined;
     url: string | undefined;
     rotation: number;
     rotation: number;
     onError: (event: SyntheticEvent<HTMLImageElement>) => void;
     onError: (event: SyntheticEvent<HTMLImageElement>) => void;
-    imgRef?: Ref<HTMLImageElement>;
+    imgRef?: MutableRefObject<HTMLImageElement | null>;
   } | null;
   } | null;
   name: string | null;
   name: string | null;
   model: string | null;
   model: string | null;
@@ -48,6 +49,9 @@ function useOverlayOrientation(layout?: OverlayLayout): OverlayLayout {
 
 
 export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
 export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  // The camera img gets its src from here, at commit, never from a prop (#3431).
+  const ownImgRef = useRef<HTMLImageElement | null>(null);
+  const attachCamera = useStreamImgRef(props.camera?.imgRef ?? ownImgRef, props.camera?.url);
   const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100;
   const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100;
   const backgroundStyle: CSSProperties & {
   const backgroundStyle: CSSProperties & {
     '--overlay-background-alpha': number;
     '--overlay-background-alpha': number;
@@ -108,8 +112,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
       {camera && (
       {camera && (
         <div className="updated-overlay__camera" ref={cameraRef}>
         <div className="updated-overlay__camera" ref={cameraRef}>
           <img
           <img
-            ref={camera.imgRef}
-            src={camera.url}
+            ref={attachCamera}
             alt={t('streamOverlay.cameraStream')}
             alt={t('streamOverlay.cameraStream')}
             style={{
             style={{
               transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
               transform: `translate(-50%, -50%) rotate(${rotation}deg)`,

+ 26 - 6
frontend/src/hooks/useStreamImgRef.ts

@@ -1,8 +1,23 @@
 import { useCallback, type MutableRefObject } from 'react';
 import { useCallback, type MutableRefObject } from 'react';
 
 
 /**
 /**
- * A ref for a camera stream <img> that closes the stream of every img the
- * component drops: unmounted, re-keyed, or hidden by a conditional.
+ * A ref for a camera stream <img> that gives it its src only once React has
+ * put it on the page, and closes the stream of every img the component drops:
+ * unmounted, re-keyed, or hidden by a conditional.
+ *
+ * Pass the stream URL here and render the img WITHOUT a src prop. React sets
+ * an element's props while it renders, before anything is committed, and the
+ * browser opens an img's request the moment its src is set, attached or not.
+ * A render React throws away (an interrupted or restarted one) never commits,
+ * so no ref sees its img and nothing can close it: one camera overlay mounting
+ * during page load opened about twenty MJPEG streams that way, each held open
+ * on a detached img until the tab closed (#3431). Set here, the src only ever
+ * reaches an img that is on the page.
+ *
+ * The src is (re)applied when the img is attached and when the URL changes,
+ * not on other re-renders: the viewers clear it themselves to close a stream
+ * (reconnect, restart, closing), and an unrelated re-render must not reopen it.
+ * ``undefined`` or '' removes it, as React does for an empty src prop.
  *
  *
  * A detached img keeps its MJPEG connection open (measured in Chromium) until
  * A detached img keeps its MJPEG connection open (measured in Chromium) until
  * the browser closes: the backend stream, the chamber light it holds and the
  * the browser closes: the backend stream, the chamber light it holds and the
@@ -15,14 +30,19 @@ import { useCallback, type MutableRefObject } from 'react';
  * restart code does, on the img still shown) fires error with an empty src:
  * restart code does, on the img still shown) fires error with an empty src:
  * error handlers must ignore an img whose src is empty or missing.
  * error handlers must ignore an img whose src is empty or missing.
  *
  *
- * Takes the component's own ref (kept up to date with the img shown) and
- * returns the callback to pass as the img's ref.
+ * Takes the component's own ref (kept up to date with the img shown) and the
+ * stream URL, and returns the callback to pass as the img's ref.
  */
  */
-export function useStreamImgRef(imgRef: MutableRefObject<HTMLImageElement | null>) {
+export function useStreamImgRef(imgRef: MutableRefObject<HTMLImageElement | null>, src: string | undefined) {
   return useCallback(
   return useCallback(
     (img: HTMLImageElement | null) => {
     (img: HTMLImageElement | null) => {
       imgRef.current = img;
       imgRef.current = img;
       if (!img) return;
       if (!img) return;
+      if (!src) {
+        if (img.hasAttribute('src')) img.removeAttribute('src');
+      } else if (img.getAttribute('src') !== src) {
+        img.src = src;
+      }
       return () => {
       return () => {
         if (imgRef.current === img) imgRef.current = null;
         if (imgRef.current === img) imgRef.current = null;
         queueMicrotask(() => {
         queueMicrotask(() => {
@@ -30,6 +50,6 @@ export function useStreamImgRef(imgRef: MutableRefObject<HTMLImageElement | null
         });
         });
       };
       };
     },
     },
-    [imgRef],
+    [imgRef, src],
   );
   );
 }
 }

+ 2 - 2
frontend/src/pages/CameraPage.tsx

@@ -84,7 +84,6 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
   const imgRef = useRef<HTMLImageElement | null>(null);
   const imgRef = useRef<HTMLImageElement | null>(null);
   // On its stream and its /camera/stop: the stop ends this page's stream only.
   // On its stream and its /camera/stop: the stop ends this page's stream only.
   const viewerId = useCameraViewerId();
   const viewerId = useCameraViewerId();
-  const attachImg = useStreamImgRef(imgRef);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -675,6 +674,8 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
     : streamMode === 'stream'
     : streamMode === 'stream'
       ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=${fps}&t=${imageKey}&viewer=${viewerId}`)
       ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=${fps}&t=${imageKey}&viewer=${viewerId}`)
       : appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
       : appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
+  // The img gets its src from here, at commit, never from a prop (#3431).
+  const attachImg = useStreamImgRef(imgRef, currentUrl);
 
 
   const isDisabled = streamLoading || transitioning || isReconnecting;
   const isDisabled = streamLoading || transitioning || isReconnecting;
 
 
@@ -905,7 +906,6 @@ function CameraView({ onPoppedIn }: { onPoppedIn: () => void }) {
           <img
           <img
             ref={attachImg}
             ref={attachImg}
             key={imageKey}
             key={imageKey}
-            src={currentUrl}
             alt={t('camera.cameraStream')}
             alt={t('camera.cameraStream')}
             className="max-w-full max-h-full object-contain select-none"
             className="max-w-full max-h-full object-contain select-none"
             style={{
             style={{

+ 13 - 9
frontend/src/pages/StreamOverlayPage.tsx

@@ -14,6 +14,7 @@ import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { mapModelCode } from '../utils/printerModel';
 import { mapModelCode } from '../utils/printerModel';
 import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
 import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
+import { useStreamImgRef } from '../hooks/useStreamImgRef';
 
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
 
@@ -335,6 +336,17 @@ export function StreamOverlayPage() {
   const fitsWindow = sideways && !config.layout && !config.updatedArtwork && config.showCamera && status != null;
   const fitsWindow = sideways && !config.layout && !config.updatedArtwork && config.showCamera && status != null;
   const windowBox = useSidewaysBox(artworkRef, fitsWindow);
   const windowBox = useSidewaysBox(artworkRef, fitsWindow);
 
 
+  // Append the kiosk token directly rather than leaning on withStreamToken's
+  // module cache — the cache is populated by an effect and would miss the first
+  // render (a 401 flash before the retry). The logged-in path keeps the cache.
+  const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
+  const streamUrl = kiosk && token
+    ? `${camPath}&token=${encodeURIComponent(token)}`
+    : withStreamToken(camPath);
+  // The classic overlay's img gets its src from here, at commit, never from a
+  // prop (#3431). Called above the early returns, as every hook must be.
+  const attachImg = useStreamImgRef(imgRef, closing ? undefined : streamUrl);
+
   if (!id) {
   if (!id) {
     return (
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
       <div className="min-h-screen bg-black flex items-center justify-center">
@@ -411,13 +423,6 @@ export function StreamOverlayPage() {
       });
       });
     }
     }
   }
   }
-  // Append the kiosk token directly rather than leaning on withStreamToken's
-  // module cache — the cache is populated by an effect and would miss the first
-  // render (a 401 flash before the retry). The logged-in path keeps the cache.
-  const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
-  const streamUrl = kiosk && token
-    ? `${camPath}&token=${encodeURIComponent(token)}`
-    : withStreamToken(camPath);
 
 
   if (config.updatedArtwork) {
   if (config.updatedArtwork) {
     const active = status.connected && isPrinting;
     const active = status.connected && isPrinting;
@@ -451,9 +456,8 @@ export function StreamOverlayPage() {
       {/* Camera feed - fullscreen background (optional) */}
       {/* Camera feed - fullscreen background (optional) */}
       {config.showCamera && (
       {config.showCamera && (
         <img
         <img
-          ref={imgRef}
+          ref={attachImg}
           key={imageKey}
           key={imageKey}
-          src={closing ? undefined : streamUrl}
           alt={t('streamOverlay.cameraStream')}
           alt={t('streamOverlay.cameraStream')}
           className="absolute inset-0 w-full h-full object-contain"
           className="absolute inset-0 w-full h-full object-contain"
           style={dimensions ? {
           style={dimensions ? {

File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-1pXnc75v.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-BzrTfqQP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-Bjy2wV8L.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-C8X4lWq4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-5osj8wUL.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-D46f3IC6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/EmbeddedCameraViewer-B6F8oslL.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/EmbeddedCameraViewer-DBmYe-Ss.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileManagerPage-B0rZDPsu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-BCQkz_16.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-DaLZ1Hi3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-V6HVGyPl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-A4Lovfbi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-Bd-gMA0a.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-DZ_b0INA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BDOcOb8a.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Ce4jxGrd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-BTTnIRH0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-CmvEYNWa.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B8I0vmF5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-TX3M68AR.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/plateCheck-DziO5lKr.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/spoolCost-UYPo-BiE.js


+ 1 - 0
static/assets/useCameraViewerId-BG_jtrZ0.js

@@ -0,0 +1 @@
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-A76-KVrT.js";var n=e(t(),1);function r(){let[e]=(0,n.useState)(()=>`v${Math.random().toString(36).slice(2,12)}`);return e}export{r as t};

+ 0 - 1
static/assets/useCameraViewerId-Do_Hn98U.js

@@ -1 +0,0 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-A76-KVrT.js";var n=e(t(),1);function r(e){return(0,n.useCallback)(t=>{if(e.current=t,t)return()=>{e.current===t&&(e.current=null),queueMicrotask(()=>{t.isConnected||t.removeAttribute(`src`)})}},[e])}function i(){let[e]=(0,n.useState)(()=>`v${Math.random().toString(36).slice(2,12)}`);return e}export{r as n,i as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/usePageFileDrop-BlkyJnHC.js


+ 1 - 0
static/assets/useSidewaysBox-CCRxxEAM.js

@@ -0,0 +1 @@
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-A76-KVrT.js";var n=e(t(),1);function r(e,t){return(0,n.useCallback)(n=>{if(e.current=n,n)return t?n.getAttribute(`src`)!==t&&(n.src=t):n.hasAttribute(`src`)&&n.removeAttribute(`src`),()=>{e.current===n&&(e.current=null),queueMicrotask(()=>{n.isConnected||n.removeAttribute(`src`)})}},[e,t])}function i(e,t){let[r,i]=(0,n.useState)(null);return(0,n.useLayoutEffect)(()=>{let n=e.current;if(!t||!n)return;let r=()=>{let e=n.clientWidth,t=n.clientHeight;e>0&&t>0&&i(n=>n&&n.width===e&&n.height===t?n:{width:e,height:t})};if(r(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,r),()=>window.removeEventListener(`resize`,r);let a=new ResizeObserver(r);return a.observe(n),()=>a.disconnect()},[e,t]),t?r:null}function a(e){return Math.abs((e??0)%180)===90}export{i as n,r,a as t};

+ 0 - 1
static/assets/useSidewaysBox-M-7Ir3rk.js

@@ -1 +0,0 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-A76-KVrT.js";var n=e(t(),1);function r(e,t){let[r,i]=(0,n.useState)(null);return(0,n.useLayoutEffect)(()=>{let n=e.current;if(!t||!n)return;let r=()=>{let e=n.clientWidth,t=n.clientHeight;e>0&&t>0&&i(n=>n&&n.width===e&&n.height===t?n:{width:e,height:t})};if(r(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,r),()=>window.removeEventListener(`resize`,r);let a=new ResizeObserver(r);return a.observe(n),()=>a.disconnect()},[e,t]),t?r:null}function i(e){return Math.abs((e??0)%180)===90}export{r as n,i as t};

+ 3 - 3
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-x2SsTO6U.js"></script>
+    <script type="module" crossorigin src="/assets/index-B8I0vmF5.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
@@ -37,7 +37,7 @@
     <link rel="modulepreload" crossorigin href="/assets/info-BD7StG1O.js">
     <link rel="modulepreload" crossorigin href="/assets/info-BD7StG1O.js">
     <link rel="modulepreload" crossorigin href="/assets/AuthContext-57h2rDlo.js">
     <link rel="modulepreload" crossorigin href="/assets/AuthContext-57h2rDlo.js">
     <link rel="modulepreload" crossorigin href="/assets/camera-MsEzBYBi.js">
     <link rel="modulepreload" crossorigin href="/assets/camera-MsEzBYBi.js">
-    <link rel="modulepreload" crossorigin href="/assets/plateCheck-MS2XNJV0.js">
+    <link rel="modulepreload" crossorigin href="/assets/plateCheck-DziO5lKr.js">
     <link rel="modulepreload" crossorigin href="/assets/useMutation-BVCKaUQ-.js">
     <link rel="modulepreload" crossorigin href="/assets/useMutation-BVCKaUQ-.js">
     <link rel="modulepreload" crossorigin href="/assets/useQueries-CNxo4TuT.js">
     <link rel="modulepreload" crossorigin href="/assets/useQueries-CNxo4TuT.js">
     <link rel="modulepreload" crossorigin href="/assets/archive-B-1F8N5G.js">
     <link rel="modulepreload" crossorigin href="/assets/archive-B-1F8N5G.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-BBLgbDES.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-DaLZ1Hi3.js">
     <link rel="stylesheet" crossorigin href="/assets/index-C2XdLYj2.css">
     <link rel="stylesheet" crossorigin href="/assets/index-C2XdLYj2.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff