Show the printer card thumbnail again after navigating back to the page (#2826)

The cover URL is cache-busted on the print name, which does not change
while a print runs. Leaving the printers page and returning therefore
re-mounts with a byte-identical src, which the browser serves from its
in-memory cache with no network request -- which is why the reporter's
network panel was empty while the placeholder sat there.

`loaded` could only ever be set by onLoad, and a mount effect reset it to
false unconditionally. For a cache hit those are two racing tasks with no
ordering between them: when the load event won, the effect undid it, and
nothing put it right afterwards because the URL does not change again for
the rest of the print. Read the element's own complete/naturalWidth in
that effect instead of assuming nothing has loaded. That settles it
whichever task wins, and also covers the variant the reporter proposed,
where the handler is not live in time.

Being a race explains why it reproduced 100% for the reporter and not at
all here. Only the printer card was affected; archive thumbnails build a
fresh URL every mount, so they always hit the network.

CoverImage is exported so the regression tests can mount it directly.
This commit is contained in:
maziggy
2026-08-14 14:04:10 +02:00
parent fffa68ec55
commit 71d506be0c
5 changed files with 225 additions and 5 deletions
+1
View File
@@ -31,6 +31,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
- **The printer card's thumbnail dropped to the placeholder after leaving the page and coming back, until the browser was reloaded (#2826, reported by @chitrangdesign)** — The thumbnail's address carries the print's name so that starting a different print fetches a different picture. That name does not change while a print runs, so returning to the printers page asked for the very same address again and the browser answered from memory without going to the network at all. The card only ever learned that its picture had arrived from the browser telling it so, and on a picture that was already in hand that announcement raced against the card's own setup, which assumed nothing had arrived yet. When the announcement came first the setup undid it, and nothing put it right afterwards, because the address stays the same for the rest of the print. The card now asks the picture whether it is showing something rather than assuming it is not, which settles the race whichever way round it happens. Being a race is also why it happened every single time for the reporter and not once on the machine it was tested on, and why nothing showed up in the browser's network panel: there was no request to show. Only the printer card was affected -- archive thumbnails use a different address every time and never had it. Covered by frontend tests.
- **A print the printer kept on its own internal storage archived as a bare name, with no thumbnail and no reason given (#2780, reported by @Utility9298 and @AntonPalmqvist)** — Bambuddy reads a print's 3MF, cover and timelapse off the printer over FTPS on port 990, and on every Bambu model that port serves external storage only — the card or the stick. It is not a view of the printer's filesystem. Under some configurations H2-series and P2S firmware keeps the sliced file on internal storage instead, and Bambu Studio uploads it there over a separate service on port 6000, so there is nothing on FTPS to fetch at any path. **This is not every H2C or P2S** — plenty archive perfectly, and a print launched from Bambuddy rather than the slicer always does, because Bambuddy uploads over FTPS itself. What decides it is where the file ended up, and the print command says which of the two it used and always has: its `url` field reads `ftp://<name>` for the card and `brtc://emmc/<name>` for internal storage. Bambuddy ignored that field and swept anyway — six filename variants across five directories with four retries for the 3MF, sixteen more paths for the cover, then the timelapse scan, roughly 110 connections per print that could not succeed. In the reporter's support bundle every one of the 35 dispatches to their H2C and P2S said internal storage and every one of the 25 to their X1C said external, and all 44 of the empty archive cards belonged to those two printers and no others. The field is now read, the sweep is skipped when it cannot succeed, and the card says which of the two reasons applies. Nothing changes for a printer that puts its prints on the card: the sweep runs exactly as before, and it also runs whenever the answer is not known — a printer whose broker refuses the request topic never sends us a `url`, and reading that silence as bad news would break archives that work today. The answer is deliberately forgotten when the print it described ends, rather than kept as a standing fact about the printer, because plenty of prints never announce themselves at all: 14 of the 79 print starts in the reporter's bundle arrived with no command on that topic, being started from the printer's own screen or picked up after a restart. Left standing, one slicer print to internal storage would have suppressed the lookup for every screen-started print after it. **This does not make the affected prints archive in full** — reading internal storage needs the port-6000 protocol, which is tracked separately as #2762. Name, timing, status and the finish photo were never affected. Two things that were actively misleading are gone with it. The archives banner told everyone the same thing — that "Store sent files on external storage" was off in the slicer and to go and turn it on — which is wrong advice for this cause and was followed twice: the reporter's three affected printers had that setting on for the entire three weeks of the log, and turning it on again changes nothing. And the connection diagnostic reported a clean pass for a printer whose slot was empty, because it read only the toggle; an empty slot is now reported as a failure with the empty slot named, and a printer that has storage and still used internal storage is reported as a warning rather than a pass. The timelapse scan is gated more narrowly than the rest on purpose: the printer writes its video to the card itself, so where the sliced file went says nothing about whether a video exists, and only a printer that reports an empty slot skips it. Covered by backend and frontend tests.
- **A failed FTP connection was dropped without closing its socket** — Every failure path left the connected socket to the garbage collector. That is survivable once and not at the volume this runs at: a single print used to walk about 110 candidate paths, so a printer refusing FTPS got that many sockets opened and abandoned in a couple of minutes, and one support bundle recorded 1813 of them in a day. It may also be part of what sustains the refusal. The message accompanying it has been corrected as well. It stated that the printer's file service was wedged and told the operator to restart the printer; #2780's reporter did that twice with no effect, and a single manual connection to the same printer completes a clean handshake and returns a valid certificate while Bambuddy is failing, so that reading was wrong. The likelier explanation is that the printer is refusing on a connection count — the refusal arrives in cleartext, which is exactly the TLS error we see, and the same limit reached globally produces the handshake timeouts that appear alongside it — but that is not yet proven, so the message now states what was observed and stops there rather than sending people to do the one thing already known not to work. Two other places gave the same advice and no longer do. Covered by backend tests.
- **A print stage Bambuddy has no name for now says so in the log** — The printer reports its current activity as a number, and Bambuddy keeps a table of what those numbers mean. The table is hand-maintained and every new model adds to it, so a printer occasionally reports one that is not in it and the card reads "Unknown stage (72)" — which happened on an H2C, where the table runs to 66 and then jumps to 74. Stage changes were logged, but at debug level, which is off in normal running: the only record that it had happened at all was the card, and by the time anyone looked the printer had moved on. An unnamed stage is now recorded at the normal log level, once per stage number, along with the model, the stage it came from and what the printer was doing at the time — which is what identifying it afterwards actually needs. Stages that do have names stay at debug as before, so a normal print logs nothing new. Fixing this turned up a latent crash beside it: the stage-change log line builds its text before the log level is consulted, so a printer reporting a stage that was not a number at all — malformed telemetry rather than an unknown stage — would abort processing of that update entirely. Labelling a value can no longer do that. Covered by backend tests.
@@ -0,0 +1,193 @@
/**
* The printer-card thumbnail has to survive an image the browser already has (#2826).
*
* The URL is cache-busted on the print *name*, which does not change while a
* print runs. So navigating away from the printers page and back re-mounts
* with a byte-identical `src`, which the browser serves from its in-memory
* cache -- no network request at all, which is why the reporter's Network
* panel was empty while the thumbnail sat on the placeholder.
*
* `loaded` used to be settable only by `onLoad`, while a mount effect reset it
* to false unconditionally. For a cache hit those two are racing tasks with no
* ordering between them, and when `load` won, the effect undid it -- and never
* ran again, because the URL does not change again during the print. That is
* why it reproduced 100% for the reporter and not at all on the maintainer's
* machine.
*
* jsdom never loads images or fires `load`, so the race itself cannot be
* staged here. What these tests pin is the invariant that makes the race
* unwinnable either way: the component must read the element's own state
* instead of assuming nothing has loaded yet.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { CoverImage } from '../../pages/PrintersPage';
vi.mock('../../hooks/useCameraStreamToken', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../../hooks/useCameraStreamToken');
return { ...actual, withStreamToken: (u: string) => u };
});
/** Present an <img> the way a memory-cache hit does: already complete. */
function stubAlreadyComplete(naturalWidth = 640) {
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
get: () => true,
configurable: true,
});
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
get: () => naturalWidth,
configurable: true,
});
}
function restoreImg() {
// @ts-expect-error removing the test-only prototype overrides
delete HTMLImageElement.prototype.complete;
// @ts-expect-error removing the test-only prototype overrides
delete HTMLImageElement.prototype.naturalWidth;
}
const URL_ = '/api/v1/printers/1/cover';
describe('CoverImage with an image the browser already has', () => {
afterEach(restoreImg);
it('shows it instead of the placeholder', async () => {
stubAlreadyComplete();
const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
await waitFor(() => {
expect(container.querySelector('img')!.className).toContain('block');
});
expect(container.querySelector('img')!.className).not.toContain('hidden');
});
it('treats it as loaded across a remount with the same print', async () => {
stubAlreadyComplete();
// First visit: warms the cache in a real browser.
const first = render(<CoverImage url={URL_} printName="Benchy" />);
await waitFor(() => expect(first.container.querySelector('img')!.className).toContain('block'));
first.unmount();
// Navigating back. Same print name means a byte-identical URL, so nothing
// is fetched -- this is the mount that used to come back blank.
const second = render(<CoverImage url={URL_} printName="Benchy" />);
await waitFor(() => {
expect(second.container.querySelector('img')!.className).toContain('block');
});
});
it('makes it clickable, not just visible', async () => {
// `loaded` also gates the click-to-enlarge overlay, so a stuck `false`
// left the thumbnail inert as well as invisible.
stubAlreadyComplete();
const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
await waitFor(() => {
expect(container.querySelector('div')!.className).toContain('cursor-pointer');
});
});
});
describe('CoverImage when nothing is cached', () => {
beforeEach(restoreImg);
it('waits behind the placeholder until the image arrives', () => {
// jsdom leaves `complete` false and never fires `load`, which is exactly
// the cold-cache state: the placeholder is correct until it resolves.
const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
expect(container.querySelector('img')!.className).toContain('hidden');
});
it('still reveals the image when onLoad fires', async () => {
const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
const img = container.querySelector('img')!;
img.dispatchEvent(new Event('load'));
await waitFor(() => expect(img.className).toContain('block'));
});
it('falls back to the placeholder when the image fails', async () => {
const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
container.querySelector('img')!.dispatchEvent(new Event('error'));
await waitFor(() => expect(container.querySelector('img')).toBeNull());
});
it('shows the placeholder when there is no cover at all', () => {
const { container } = render(<CoverImage url={null} printName="Benchy" />);
expect(container.querySelector('img')).toBeNull();
});
});
describe('CoverImage when the print changes', () => {
afterEach(restoreImg);
it('re-evaluates rather than carrying the previous print forward', async () => {
// The reset-on-change behaviour the effect was added for in the first
// place still has to hold: a new print name is a new URL, and a fresh
// element that is not yet complete must go back behind the placeholder.
stubAlreadyComplete();
const { container, rerender } = render(<CoverImage url={URL_} printName="Benchy" />);
await waitFor(() => expect(container.querySelector('img')!.className).toContain('block'));
restoreImg();
rerender(<CoverImage url={URL_} printName="Something Else" />);
await waitFor(() => {
expect(container.querySelector('img')!.className).toContain('hidden');
});
});
it('keeps the cache-buster tied to the print name', () => {
stubAlreadyComplete();
const { container, rerender } = render(<CoverImage url={URL_} printName="Benchy" />);
const first = container.querySelector('img')!.getAttribute('src');
rerender(<CoverImage url={URL_} printName="Other" />);
const second = container.querySelector('img')!.getAttribute('src');
expect(first).toContain('v=Benchy');
expect(second).toContain('v=Other');
expect(first).not.toEqual(second);
});
it('reuses the URL for the same print, which is what makes the cache hit', () => {
// Pinning the precondition, not an incidental detail: if this ever became
// unique per mount the bug would vanish and so would the caching, and the
// tests above would silently stop covering anything.
stubAlreadyComplete();
const a = render(<CoverImage url={URL_} printName="Benchy" />);
const first = a.container.querySelector('img')!.getAttribute('src');
a.unmount();
const b = render(<CoverImage url={URL_} printName="Benchy" />);
const second = b.container.querySelector('img')!.getAttribute('src');
expect(second).toEqual(first);
});
});
describe('CoverImage with a broken cached image', () => {
afterEach(restoreImg);
it('does not treat a zero-width complete image as loaded', async () => {
// `complete` is also true for an image that failed. Width is what
// separates "decoded and ready" from "finished, with nothing to show".
stubAlreadyComplete(0);
const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
await waitFor(() => expect(container.querySelector('img')).not.toBeNull());
expect(container.querySelector('img')!.className).toContain('hidden');
});
});
+29 -3
View File
@@ -943,7 +943,7 @@ function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | nu
const DRY_START_CONFIRM_MS = 30_000;
function CoverImage({
export function CoverImage({
url,
printName,
className = 'w-20 h-20',
@@ -958,6 +958,7 @@ function CoverImage({
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [showOverlay, setShowOverlay] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
// Cache-bust the image URL when the print name changes so the browser
// fetches the new cover instead of serving the stale cached image.
@@ -967,10 +968,34 @@ function CoverImage({
return withStreamToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`);
}, [url, printName]);
// Reset loaded/error state when the image URL changes
// Re-evaluate load state when the image URL changes, and ask the element
// whether it is already showing something rather than assuming it is not.
//
// `onLoad` used to be the only thing that could set `loaded`, and this
// effect reset it to false unconditionally. That is right when the URL
// really changes, but it also runs on mount — and the two are not the same
// situation (#2826). The URL is cache-busted on the print *name*, which is
// constant for the duration of a print, so navigating away from the
// printers page and back re-mounts with a byte-identical src that the
// browser serves from its in-memory cache. The `load` event for a cache hit
// and React's passive-effect flush are both plain tasks with no ordering
// between them, so when `load` won, this effect ran afterwards and undid
// it: `loaded` stayed false, the img stayed `hidden`, and the placeholder
// sat there until a full reload. Nothing recovered it, because the URL
// never changes again during the print and so this effect never re-runs.
//
// Being a race, it reproduced every time for #2826's reporter and not at
// all here, which is also why the network panel showed no request: there
// was no request to show.
//
// Asking `complete && naturalWidth > 0` settles it without needing to know
// which of the two ran first. It is also correct for the case the reporter
// proposed (a `load` that fires before React's handler is live), so the
// fix stands whichever mechanism is really at work.
useEffect(() => {
setLoaded(false);
setError(false);
const el = imgRef.current;
setLoaded(Boolean(el?.complete && el.naturalWidth > 0));
}, [cacheBustedUrl]);
return (
@@ -982,6 +1007,7 @@ function CoverImage({
{cacheBustedUrl && !error ? (
<>
<img
ref={imgRef}
src={cacheBustedUrl}
alt={t('printers.printPreview')}
className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CwdtRDU5.js"></script>
<script type="module" crossorigin src="/assets/index-Cin_PtM9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1Ya6fAmN.css">
</head>
<body>