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 4 days ago
parent
commit
d67c28c6bd
40 changed files with 321 additions and 32 deletions
  1. 1 0
      CHANGELOG.md
  2. 171 0
      frontend/src/__tests__/components/streamImgSrcAtCommit3431.test.tsx
  3. 22 0
      frontend/src/__tests__/pages/CameraPage.test.tsx
  4. 21 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  5. 48 0
      frontend/src/__tests__/streamImgSpy.ts
  6. 2 2
      frontend/src/components/CameraTile.tsx
  7. 2 2
      frontend/src/components/EmbeddedCameraViewer.tsx
  8. 7 4
      frontend/src/components/UpdatedStreamOverlay.tsx
  9. 26 6
      frontend/src/hooks/useStreamImgRef.ts
  10. 2 2
      frontend/src/pages/CameraPage.tsx
  11. 13 9
      frontend/src/pages/StreamOverlayPage.tsx
  12. 0 0
      static/assets/ArchivesPage-1pXnc75v.js
  13. 0 0
      static/assets/CamWallPage-BzrTfqQP.js
  14. 0 0
      static/assets/CameraPage-Bjy2wV8L.js
  15. 0 0
      static/assets/CameraPage-C8X4lWq4.js
  16. 0 0
      static/assets/CameraWall-5osj8wUL.js
  17. 0 0
      static/assets/CameraWall-D46f3IC6.js
  18. 0 0
      static/assets/EmbeddedCameraViewer-B6F8oslL.js
  19. 0 0
      static/assets/EmbeddedCameraViewer-DBmYe-Ss.js
  20. 0 0
      static/assets/FileManagerPage-B0rZDPsu.js
  21. 0 0
      static/assets/InventoryPage-BCQkz_16.js
  22. 0 0
      static/assets/Layout-DaLZ1Hi3.js
  23. 0 0
      static/assets/ModelSourcesPage-V6HVGyPl.js
  24. 0 0
      static/assets/PrintersPage-A4Lovfbi.js
  25. 0 0
      static/assets/SettingsPage-Bd-gMA0a.js
  26. 0 0
      static/assets/SliceModal-DZ_b0INA.js
  27. 0 0
      static/assets/SpoolBuddyInventoryPage-BDOcOb8a.js
  28. 1 1
      static/assets/SpoolBuddySettingsPage-Ce4jxGrd.js
  29. 0 0
      static/assets/StreamOverlayPage-BTTnIRH0.js
  30. 0 0
      static/assets/StreamOverlayPage-CmvEYNWa.js
  31. 0 0
      static/assets/index-B8I0vmF5.js
  32. 0 0
      static/assets/locationSensorDefaults-TX3M68AR.js
  33. 0 1
      static/assets/plateCheck-DziO5lKr.js
  34. 0 0
      static/assets/spoolCost-UYPo-BiE.js
  35. 1 0
      static/assets/useCameraViewerId-BG_jtrZ0.js
  36. 0 1
      static/assets/useCameraViewerId-Do_Hn98U.js
  37. 0 0
      static/assets/usePageFileDrop-BlkyJnHC.js
  38. 1 0
      static/assets/useSidewaysBox-CCRxxEAM.js
  39. 0 1
      static/assets/useSidewaysBox-M-7Ir3rk.js
  40. 3 3
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -9,6 +9,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### Changed
 ### Changed
 
 
 ### Fixed
 ### Fixed
+- **A camera window no longer opens a dozen streams to the printer at once (#3431, reported by @buffdriver)** — When the floating camera window was open while the page loaded (after a refresh, for example), Bambuddy opened about twenty camera streams for it. Only one was shown. The browser kept the rest open until the tab was closed, and they filled its six connections to the server. The page started each stream while still preparing to draw the window. It prepared the window several times before showing it, and every attempt left a stream behind. Streams now start only once the window is on the page. The camera page, the camera wall and the stream overlay start their streams the same way.
 - **A printer whose file service hangs during a print is no longer asked again and again until it refuses every upload (#3474, reported by @F9mm)** — When the card could not be read at print start, the timelapse baseline was read again every two minutes for the whole print, and each round walked the card twice: up to eight new FTPS connections, each hanging for 30 s against a hung printer. An H2S kept a session for every one of them, then answered every connection with "421 There are too many connections", and no queued job could reach it until the printer was restarted. The card is now read once per round. It is read again only while the print records a timelapse, the wait doubles after each failed round (up to 30 minutes), and it stops after five. A connection or listing that times out now pauses the background timelapse scans of that printer for five minutes. Opening files and sending prints are not held up by this. A printer that answers "421 too many connections" now gets its own message in the queue, saying it may need a restart, instead of "Bambuddy will retry automatically". Wiki updated.
 - **A printer whose file service hangs during a print is no longer asked again and again until it refuses every upload (#3474, reported by @F9mm)** — When the card could not be read at print start, the timelapse baseline was read again every two minutes for the whole print, and each round walked the card twice: up to eight new FTPS connections, each hanging for 30 s against a hung printer. An H2S kept a session for every one of them, then answered every connection with "421 There are too many connections", and no queued job could reach it until the printer was restarted. The card is now read once per round. It is read again only while the print records a timelapse, the wait doubles after each failed round (up to 30 minutes), and it stops after five. A connection or listing that times out now pauses the background timelapse scans of that printer for five minutes. Opening files and sending prints are not held up by this. A printer that answers "421 too many connections" now gets its own message in the queue, saying it may need a restart, instead of "Bambuddy will retry automatically". Wiki updated.
 
 
 ### Security
 ### Security

+ 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