fix(ui): viewer progress-image handoff: slow reveal, overlay stuck after socket drop, stale-image flash on quick re-generate - #9434
Conversation
…king The image viewer holds the last progress preview on screen until the final image's onLoad fires. Two problems with that. The reveal was gated on a preload of imageDTO.image_url — the full-resolution PNG — so on a slow connection the stale latent preview stayed up for the entire multi-megabyte download. A 256px thumbnail is already generated for every image and is typically higher resolution than the preview it replaces. Gate on that instead; DndImage renders it via Chakra's fallbackSrc and swaps the full image in, in place, once it arrives. The preload also used the raw URL while DndImage requests useMediaUrl(...), which appends ?media_cookie_version=N. Different key, so the bytes were fetched twice (measured: 2 requests mismatched vs 1 matched). Route the preload through useMediaUrl so it is byte-identical. The reuse is the document's list of available images, keyed by URL rather than the HTTP cache, so it still holds in multiuser mode where images are served Cache-Control: private, no-store. Separately, the viewer's progress atoms are distinct stores from the global ones in services/events/stores, and only the latter were reset on socket lifecycle transitions. socket.io has no event replay, so a drop spanning the terminal queue_item_status_changed loses that event permanently and nothing is left to clear the opaque overlay covering the finished image — the reported "backgrounded the tab, came back, only a reload fixes it". Reset the viewer's atoms on connect/connect_error/disconnect too, matching setEventListeners. onLoadImage is not a guaranteed callback in any case: Chakra reports a failed load as onError, useImage only re-runs when src changes, the load can beat the terminal event, and an all-intermediate item never changes the selection. So the deferred clear also gets a backstop deadline. The armed flag and its timer live together in createDeferredClear — as separate state, a path that reset the flag but leaked the timer let a deadline outlive the generation that armed it and blank a later one's live preview. The backstop does not clear while other sessions still have previews, since nulling $progressImage tears down the whole overlay including multi-GPU tiles, and the reconnect reset only replaces the map when it holds something, because connect_error fires once per reconnection attempt. The terminal-status policy moves to a pure getTerminalProgressAction so the branchy decision is testable without a socket or a React tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…review Starting a new generation soon after the previous one finishes made the viewer flicker: the new previews would appear, then the previous generation's finished image would cover them for two seconds, then the previews resumed. Waiting between generations avoided it. The flash is the "reveal selected image" feature (invoke-ai#9217), which briefly hides the progress overlay so a mid-generation gallery click is visible. Its only guard against the auto-switch handoff was $isProgressImageResolving — a timing guard, and the timing loses: the auto-switch selection is dispatched only after onInvocationComplete's async DTO fetch, then waits for the thumbnail preload, and the next generation's first invocation_progress event slots into that window and resets the flag. By the time the handoff reaches the viewer it is indistinguishable from a user click, so the reveal fires over the live preview. Distinguish them by identity instead of timing: auto-switch records the image name in a small registry at dispatch, and the reveal effect consumes it on the selection's first render. Consumption happens on every rendered-image change, not only when the reveal conditions hold, because in the common (unraced) case the image renders with no progress showing and a leftover entry would suppress a genuine user selection of the same image later. Entries also expire after 30 seconds. Recording is unconditional but consumption requires the image to actually render, so a superseded auto-switch (two completions within one thumbnail-fetch window — routine with parallel multi-GPU sessions), a viewer unmounted by comparison mode, or a duplicate invocation_complete event would otherwise leave an immortal entry whose only future effect is to swallow a genuine click on that image — the very dead-click the reveal exists to prevent. The TTL is generous for the dispatch-to-render handoff it protects; expiring early merely readmits the 2-second flash on a very slow connection, which is the milder failure. The suppression branch still lowers $isTemporarilyShowingSelectedImage — the effect has already cancelled any running reveal's timer by that point, so returning with the atom raised would wedge the reveal on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
40a6a4c to
d86b6f6
Compare
|
@lstein Should this be held for v7? |
I'd prefer to get it into 6.4.1. |
JPPhoto
left a comment
There was a problem hiding this comment.
Some changes:
-
invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:88(onResolveDeadline): A+B previews can strand overlay. A owns globals; A completes; deadline sees B active, only disarms, leaves A progress image. B terminal then returnsignorebecause owner still A; map empty but overlay remains.Test:progress B, progress A, complete A, advance 30s without image load, complete B; assert overlay clears. -
invokeai/frontend/web/src/features/gallery/store/autoSwitchedImages.ts:51(record), fed byonInvocationComplete.tsx:204: duplicate completion guard logs but handler continues, re-recording image A after its first render consumed A. Later user selection of A within 30s is misclassified as auto-switch; reveal is suppressed. Rapid A -> B before A renders strands the same stale entry.Test:duplicate same completion, consume A once, then select B -> A during progress; assert user reveal occurs.
Alternative implementation ideas:
-
Instead of checking only “any active tile”, track resolve owner; deadline should promote the active tile/owner before disarming, or clear only when no active item remains.
-
Instead of an append-only name queue, use cancellable selection tokens and return before gallery work on duplicate completion.
JPPhoto
left a comment
There was a problem hiding this comment.
Merge blockers:
invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:190: Deadline promotion can make B owner; if B later cancels/fails while C remains active, clear nulls the shared image and hides C's tile. Test: promote B, cancel B, keep C active without another preview event; assert C remains visible.
Other findings/issues:
-
invokeai/frontend/web/src/services/events/onInvocationComplete.tsx:374-384: Processed key is permanent before awaited DTO work; transient null/error means gallery update is lost and duplicate retry is ignored. Test: first DTO lookup fails, second succeeds, deliver event twice; expect retry. -
invokeai/frontend/web/src/features/gallery/store/autoSwitchedImages.ts:67: Name-only marker can suppress a real click on A when an auto-switch to A never rendered and A is selected again before TTL. Test: keep B rendered, record A, reselect B without a render change, then select A; assert reveal occurs.
Suggestions:
-
Instead of clearing unconditionally, promote another active session whenever progress data remains; clear only after the last tile ends.
-
Instead of permanent pre-await keys, dedupe with in-flight promises and remove keys after failed gallery work.
-
Instead of name-only TTL markers, carry an explicit auto-switch token or clear markers on explicit user selection.
Main's invoke-ai#9389 (viewer preview lifecycle) rewrote the same machinery this branch touched, so this is a semantic reconciliation, not a textual one: - context.tsx: taken from main wholesale. The lifecycle module supersedes this branch's createDeferredClear/getTerminalProgressAction backstop (setPendingResolve + RESOLVE_TIMEOUT_MS) and its deadline promotion — onTerminal now hands the shared preview to the freshest surviving session at terminal time, so the stranded-owner state cannot arise. Its disconnect/queue-cleared resets supersede this branch's socket lifecycle clearing. progressImageResolution.ts and its tests are deleted accordingly. - CurrentImagePreview.tsx: keeps this branch's thumbnail reveal gating and auto-switch reveal suppression, adapted to the identity-gated onLoadImage(sessionId) signature — the preload's onReady now passes the DTO's session id so the lifecycle can attribute the load. - context.test.ts replaced by CurrentImagePreview.test.ts: the wiring checks for the deleted machinery are covered by main's real lifecycle unit tests; the thumbnail-gating, preload-settle, and registry-settle checks remain. - autoSwitchedImages, onInvocationComplete dedupe: unchanged (merged cleanly), still unique to this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Heads-up for re-review: |
… + lookup-failure retry Two of JPPhoto's findings applied to the current head (his third, the deadline-promotion blocker, was fixed ahead of this round by the merge of main's invoke-ai#9389: the lifecycle's onTerminal hands the shared preview to the freshest surviving session on every terminal status, and its test 'keeps promoting through a chain of terminations' pins his exact promote-B/cancel-B/C-still-active sequence). 1. The auto-switch marker is now scoped to the selection it was recorded for, not keyed by image name with a TTL. A redux listener settles the marker on every action that moves the gallery selection (matched by state change, not action type, so new selection-writing reducers are covered automatically). An auto-switch that never renders — because the user clicked elsewhere first, even without a rendered-image change — is dropped the moment the selection moves on, so it can never swallow the user's later click on that image. At most one marker exists, and only while its selection stands, so the TTL and pending bound are gone. 2. A completion delivery whose DTO lookups all fail no longer poisons the dedupe key: the key is dropped so a re-delivery can redo the gallery work instead of being turned away as a duplicate of a delivery that never landed. Partial failures keep the key — the fetched DTOs' board totals and optimistic inserts were already dispatched, and a retry would double-count them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — all three findings addressed in 6e263ff. Blocker — promotion chain (promote B, cancel B, C still active): confirmed against the head you reviewed, and it was fixed by the merge of #9389 that landed just before your review: the deadline-promotion code you cited is gone entirely. The lifecycle's Pre-await dedupe key vs transient DTO failure: fixed per your suggestion. Name-only marker swallowing a real click: fixed per your suggestion ("clear markers on explicit user selection") — the registry is replaced by a selection-scoped marker. A redux listener ( All checks green locally: tsc/eslint/prettier/dpdm/knip clean, 1912 tests passing. |
…ry work
An adversarial review of the round-3 changes found the mechanism sound but
its verification hollow: deleting the auto-switch record(), or making the
suppression branch unreachable, left all 1888 tests green. Both mutations
remove the behavior this PR exists to deliver.
- The reveal decision moves into a pure getSelectedItemRevealDecision, unit
tested branch by branch. It answers 'reveal' or 'hide' and nothing else:
the caller clears the running reveal's timer before asking, so a path
that returned without writing the atom would strand the reveal on with
no timer left to end it. That was reachable in the old shape via the
previous-name early return.
- onInvocationComplete gains tests that the auto-switched selection is
actually marked (and is not when auto-switch is off), and that the
video half of the retry condition is load-bearing, via a mixed
image+video result.
Two lower-severity findings from the same review:
- A retry re-ran the whole handler, including two side effects that are
global rather than per-event: the canvas processing flag and
$lastProgressEvent. A re-delivery arriving after the user started
another run would stop that run's spinner and blank its progress. The
dedupe entry now records what is outstanding ('done' vs
'gallery-retryable') and a retry redoes only the gallery work.
- The retry condition read "DTOs fetched", but two paths return before
dispatching anything — a first intermediate image, and an all-
intermediate video result. An event whose surviving output was
intermediate therefore kept its key with nothing dispatched, so the
lookup that failed alongside it could never be retried. The counts now
mean "gallery work dispatched".
Every fix above is pinned by a test that fails without it.
… into fix/viewer-progress-image-handoff
|
All three addressed. Head is now Merge blocker — deadline promotion, then the promoted owner cancels while another session is activeFixed by the rebase rather than by a patch. #9389 landed Name-only marker suppresses a real clickConfirmed, and it is the case my round-2 fix could not reach: settling on renders can't see an auto-switch that never rendered, so the marker outlived it and ate the user's later click on that image. Took your token suggestion. The marker is now scoped to the selection it was recorded for — a single slot, settled by a redux listener on every selection change, no TTL and no bound:
Your exact sequence — keep B rendered, record A, reselect B, then select A — is a test, as is the listener wiring against real Processed key permanent before the awaited DTO workConfirmed — Two things a fresh-context review of that fix turned up, both fixed here:
VerificationThe same review made the point worth more than the findings: the reveal suppression had no real coverage — deleting One adjacent defect I did not touch
|
knip fails the frontend checks on it: SelectedItemRevealDecision is only ever the return type of the function declared beside it.
JPPhoto
left a comment
There was a problem hiding this comment.
Please address these:
-
invokeai/frontend/web/src/services/events/onInvocationComplete.tsx:402-454: Low-frequency race. A duplicate completion rejected during a transient DTO-fetch failure cannot retry after the first call deletes its key. User sees a completed output missing from the gallery, with no error. Test: overlap two calls, resolve the first lookup tonull, and verify a retry occurs without a third event. -
invokeai/frontend/web/src/services/events/onInvocationComplete.tsx:254-279,447-454: Partial failures are plausible for image collections, though uncommon for single-image generations. One failed output plus one successful output permanently dedupes the event; the user may see most outputs but miss one, miss its auto-switch, and see stale board counts. A later gallery refetch may recover it. Test: fail one collection lookup, succeed it on redelivery, and verify it is processed.
Corner/impossible cases that can safely be ignored (but should be mentioned for future reference):
invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts:19-25: Rare lifecycle edge. If logout/session expiry or viewer teardown occurs before an auto-switched image renders, the unchanged selection leaves the marker alive. A later render can suppress the temporary user-reveal effect while progress is visible; no data is lost. Test: record item A, expire the session without changing selection, then render A during progress.
Suggestions:
- Instead of event-wide dedupe counts, track each output independently; retry only missing DTOs while keeping successful outputs idempotent.
- Instead of rejecting concurrent duplicates outright, share the in-flight result and retry when all lookups fail.
- Instead of a global marker surviving lifecycle changes, reset it on logout, session expiry, and viewer teardown.
… dedupe, reveal controller Two merge blockers and four findings from JPPhoto's review. 1. The name-keyed auto-switch registry is replaced with invoke-ai#9434's selection-scoped marker, taken verbatim (autoSwitchedImages.ts, the settle listener, and its tests are byte-identical between the two branches, so whichever merges second does so cleanly). A marker now survives only while the selection it was recorded for stands: a redux listener settles it on every selection move, so an auto-switch that never rendered can no longer swallow a later genuine click on the same item — the miss-leaves-marker-pending blocker. 2. Duplicate completion deliveries are deduped at the top of the handler (also ported from invoke-ai#9434, which grew the same guard for the image path), so a re-delivered video completion no longer re-records the marker or re-dispatches the auto-switch selection. A delivery whose DTO lookups all fail drops its dedupe key so a re-delivery can retry; partial failures keep it, since the fetched DTOs' gallery work already went out. addVideosToGallery keeps this branch's marker record for the video auto-switch — the one line invoke-ai#9434's copy of the file doesn't have. 3. The reveal sequencing moves out of the two preview components' effects into a shared controller (selectedItemReveal.ts) whose sequences are unit-tested, closing the three holes found in the inline version: - StrictMode's double-invoked mount effect killed every cross-media first reveal in development: the second run found the shared ref already holding the name the first run wrote. The controller remembers which item its own in-flight reveal is showing and re-arms instead of lowering. - A click landing inside a resolve window was dead forever: the ref advanced past it before the resolving guard ran. The controller leaves the ref and the marker untouched while resolving, so the click (or auto-switch) keeps its identity and is classified when the window ends. - Clearing the selection reset the ref to null, making the next click read as the viewer's first render. A cleared selection now moves the ref to a sentinel that is neither null nor a name, so the next selection — including the same item — reveals. 4. The metadata panel is gated on !isPlaying and !isTemporarilyShowingSelectedImage explicitly rather than riding on !withProgress, which both of those states turn off — it used to land exactly on top of the native controls or the just-revealed video. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… rework Carried across from invoke-ai#9475, which shares this code — the two handlers stay one implementation. - A result can name the same image twice (an image collection concatenates its inputs without deduping). Each occurrence was fetched and counted separately, inflating the board total; and because the retry set is keyed by name, a retry re-admitted the occurrence that had already landed. Outputs are fetched once per distinct name. - An intermediate output returned from the whole pass, abandoning siblings that belong in the gallery. Survivable when the dedupe was event-wide; with per-output tracking those siblings are in nobody's missing set, so nothing could recover them. Intermediates are filtered, as the video path already did. - A retry re-ran the auto-switch, moving the user's selection (and possibly their board) long after they had chosen something else. - A throw inside the gallery work escaped as an unhandled rejection — both call sites discard this handler's promise. - addBoardIdSelectedListener matched galleryViewChanged, which the auto-switch dispatches immediately before imageSelected. The probe it started woke on that very selection and re-selected the first name in a stale list, undoing the auto-switch and revealing the wrong image over the live preview. An explicit selection now cancels the probe. Also pins that consuming one item's marker cannot spend another's.
The completion-dedupe port in the previous commit took invoke-ai#9434's state at the time, which JPPhoto has since found two defects in — both now fixed there and brought across so the branches stay one implementation: - A duplicate arriving while the first delivery was still fetching was rejected outright, so when that delivery lost its output to a failed DTO lookup, the only event that could have retried it was already gone. Duplicates now await the in-flight delivery and become its retry; several waiters serialize into one. - The dedupe was event-wide, so a partial failure (plausible for image collections) permanently abandoned the output that failed while the rest landed. The entry now names the outputs whose lookup failed and a retry re-fetches only those, which is what makes recovering a partial failure safe from double-counting. The two handlers are now identical but for the video auto-switch marker record, which is this branch's. Also moves the three video-workflow query hooks out of the @knipignore block in videos.ts: they have call sites on this branch, so knip fails the frontend checks on the unused tags. Same change invoke-ai#9434 carries.
… rework All five are in code this branch shares with invoke-ai#9434, and are fixed there too. - A result can name the same image twice (an image collection concatenates its inputs without deduping). Each occurrence was fetched and counted separately, inflating the board total — and because the retry set is keyed by name, a retry re-admitted the occurrence that had already landed and counted it again. Outputs are now fetched once per distinct name. - An intermediate output returned from the whole pass, abandoning siblings that belong in the gallery. That was survivable when the dedupe was event-wide; with per-output tracking those siblings are in nobody's missing set, so no re-delivery could ever recover them. Intermediates are now filtered, as the video path already did. - A retry re-ran the auto-switch, moving the user's selection (and possibly their board) long after they had chosen something else. A retry now lands the lost output and nothing more. - A throw inside the gallery work escaped as an unhandled rejection: both call sites discard this handler's promise. It is logged, and the outputs whose lookups failed are still recorded as retryable. - addBoardIdSelectedListener matched galleryViewChanged, which the auto-switch dispatches immediately before imageSelected. The probe it started woke on that very selection and re-selected the first name in a stale list, undoing the auto-switch — and the viewer then revealed that wrong image over the live preview, which is exactly the flash the marker exists to prevent. An explicit selection now cancels the probe. Each fix has a test that fails without it, including a real-store test for the listener.
|
Follow-up to the round-4 fixes: two fresh-context reviews of that rework found five defects in it, all fixed at
And the adjacent one I flagged last round as a follow-up, which a review then verified end to end with the real slice and both listeners — so I fixed it here rather than deferring:
Each fix has a test that fails without it. The suite is 1932 tests with tsc, eslint, prettier and knip clean. On your corner case: the marker does not survive logout. The gallery slice clears #9475 carries the same marker, listener and handler; both are updated together so they stay one implementation. |
JPPhoto
left a comment
There was a problem hiding this comment.
This one should be addressed:
invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts:9-22,37-47: The pending board/view probe is canceled forimageSelected, but normal thumbnail and keyboard paths useselectionChanged. The probe can later select its cached first item or clear selection after 5 seconds. Effect: user's choice is overwritten or appears ignored. Likelihood: plausible while a board list is loading. Recovery: manually reselect. Test: dispatchgalleryViewChanged, thenselectionChanged(['picked.png']), advance 6 seconds, and assert the selection remainspicked.png.
Corner/impossible cases (worth mentioning but not addressing):
invokeai/frontend/web/src/services/events/onInvocationComplete.tsx:76,520-549: More than 1,000 distinct completions can evict an older key while its DTO request remains in flight. A later duplicate then starts a second full gallery pass. Effect: optimistic board counts can double. Likelihood: rare; requires a large burst before one lookup settles. Recovery: later board refetch or reload. Test: hold the first DTO request, process 1,001 distinct events, then deliver the first duplicate.
Suggestions:
-
Instead of canceling only on
imageSelected, also matchselectionChanged, or cancel based on any active-selection state transition. -
Instead of using the evictable LRU for in-flight outcomes, retain an in-flight result separately and recheck it after awaiting; this prevents eviction from reprocessing duplicates.
Carried from invoke-ai#9475, where JPPhoto found it: the cancellation added last round matched imageSelected only, but thumbnail clicks, range selects and keyboard navigation dispatch selectionChanged. A board or view change followed by any of those left the probe running, so it still replaced or cleared what the user had just picked — and the auto-switch path this PR protects goes through exactly that window. The listener now matches the change of active item rather than a list of actions.
…puts Carried from invoke-ai#9475, where JPPhoto raised both as merge blockers; this branch shares the listener and the completion handler. - The board/view probe compared only the active item, so narrowing a multi-selection or re-picking the item already active left it running to overwrite the user's selection. It now compares the whole selection. - An output lost to a transient DTO lookup failure was only recovered if the server happened to re-deliver the completion event, which nothing makes it do. Missing outputs are now refetched at 1s, 3s and 9s, bounded, each attempt taking the same path as a re-delivery so landed outputs are untouched and the global side effects are not re-run.
|
Head is The That fix has since been superseded, because on #9475 you found the case it still missed: it matched the change of the active item, which narrowing a multi-selection or re-picking the already-active item leaves unchanged. It now compares the whole selection, which covers those and anything added later. Also carried down from #9475, where you raised it as a blocker: outputs lost to a transient DTO lookup failure are refetched at 1s / 3s / 9s rather than waiting for a duplicate completion event that nothing makes the server send. On the LRU corner case you flagged: worth noting the shape changed slightly, in the safer direction — a scheduled refetch holds no key of its own, so an evicted entry turns its pending timer into a no-op rather than starting a second full pass. Keeping the in-flight outcome outside the evictable cache is still the right fix if it ever bites. Suite is 1940 tests with tsc, eslint, prettier, knip and dpdm clean. |
|
I'm going to work on these now:
Corner/impossible cases I'm ignoring:
|
…verlay fix The two PRs had become one change maintained in two branches: three commits existed in both with identical subjects, 9475's marker and completion dedupe were ported byte-identical from 9434, and 9434 had since taken 9475's extracted reveal controller. They conflicted in four files in either merge order, so the split was costing a resolution per round with a standing risk of the two copies drifting. The reveal is resolved to 9475's controller throughout. 9434 carried a stateless getSelectedItemRevealDecision() whose caller managed the previous-item ref, the auto-switch marker and the timer by hand; the controller owns all of it and adds the sequencing 9434's version had no way to express -- resolve-window deferral, the SELECTION_CLEARED sentinel, and the StrictMode re-arm. Every branch the decision function encoded has a counterpart test on the controller. Everything else 9434 contributed to those files is kept: the thumbnail-gated preload (gating on the full-resolution image held a stale latent preview on screen for the whole download), the overlay clear on preload error as well as success, and its onInvocationComplete coverage. CurrentImagePreview's wiring test is rewritten against the controller. Two of its four assertions described the inlined implementation; one is now covered by a real unit test on the controller (the marker is consumed on every rendered-item change even with no progress showing), and the other becomes the same routing check CurrentVideoPreview already carries. The only test dropped without a counterpart asserted that the decision function returned nothing but 'reveal' or 'hide' -- a statement about an API that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge resolution replaced invoke-ai#9434's inlined reveal with this branch's controller, and rewrote the wiring test that went with it. That test carried the only assertion on either branch that the component writes $isTemporarilyShowingSelectedImage -- it matched the literal hide path the inlined version had -- and the rewrite dropped it. Nothing else covers it. selectedItemReveal.test.ts substitutes its own setRevealed, so the controller tests are structurally incapable of observing the atom, and CurrentVideoPreview's assertions are all on the read side (withProgress, the metadata gate). Neither component is ever mounted: this directory has no DOM test environment. An adversarial review of the merge proved the gap by replacing setRevealed with a no-op in both previews: all 26 assertions across the two wiring tests still passed, and so did the full suite, with the reveal completely dead -- a mid-render gallery click doing nothing, which is the bug both PRs exist to fix. Both tests now fail against that mutation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Closing in favour of #9475, which now contains this PR's commits. The two had converged into a single change maintained in two branches: three commits existed in Everything here is preserved in #9475 — the thumbnail-gated preload and its Two things did not carry over unchanged, both called out in #9475's description:
Review history stays here; please re-review on #9475. |
…into the reveal machine invoke-ai#9475 absorbed invoke-ai#9434, so this branch picks up invoke-ai#9434's thumbnail-gated preload, its error-path overlay clear and its onInvocationComplete coverage, all of which merge cleanly onto the machine. One conflict, in CurrentVideoPreview's wiring test: invoke-ai#9475 gained an assertion that the reveal is actually connected to $isTemporarilyShowingSelectedImage, after an adversarial review showed the whole suite stayed green with that wiring replaced by a no-op. Carried forward against this branch's shape — the machine is built once in context.tsx for both previews, so the check moves there and is made once rather than per component. CurrentImagePreview's wiring test arrived from the merge still describing the controller this branch replaced, and is rewritten against the machine: sync, the item-named readiness, attach, and negative assertions that none of the three superseded implementations survive beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es or videos (#9475) * fix(ui): resolve the viewer preview on the thumbnail and stop it sticking The image viewer holds the last progress preview on screen until the final image's onLoad fires. Two problems with that. The reveal was gated on a preload of imageDTO.image_url — the full-resolution PNG — so on a slow connection the stale latent preview stayed up for the entire multi-megabyte download. A 256px thumbnail is already generated for every image and is typically higher resolution than the preview it replaces. Gate on that instead; DndImage renders it via Chakra's fallbackSrc and swaps the full image in, in place, once it arrives. The preload also used the raw URL while DndImage requests useMediaUrl(...), which appends ?media_cookie_version=N. Different key, so the bytes were fetched twice (measured: 2 requests mismatched vs 1 matched). Route the preload through useMediaUrl so it is byte-identical. The reuse is the document's list of available images, keyed by URL rather than the HTTP cache, so it still holds in multiuser mode where images are served Cache-Control: private, no-store. Separately, the viewer's progress atoms are distinct stores from the global ones in services/events/stores, and only the latter were reset on socket lifecycle transitions. socket.io has no event replay, so a drop spanning the terminal queue_item_status_changed loses that event permanently and nothing is left to clear the opaque overlay covering the finished image — the reported "backgrounded the tab, came back, only a reload fixes it". Reset the viewer's atoms on connect/connect_error/disconnect too, matching setEventListeners. onLoadImage is not a guaranteed callback in any case: Chakra reports a failed load as onError, useImage only re-runs when src changes, the load can beat the terminal event, and an all-intermediate item never changes the selection. So the deferred clear also gets a backstop deadline. The armed flag and its timer live together in createDeferredClear — as separate state, a path that reset the flag but leaked the timer let a deadline outlive the generation that armed it and blank a later one's live preview. The backstop does not clear while other sessions still have previews, since nulling $progressImage tears down the whole overlay including multi-GPU tiles, and the reconnect reset only replaces the map when it holds something, because connect_error fires once per reconnection attempt. The terminal-status policy moves to a pure getTerminalProgressAction so the branchy decision is testable without a socket or a React tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): stop auto-switch flashing the previous image over the next preview Starting a new generation soon after the previous one finishes made the viewer flicker: the new previews would appear, then the previous generation's finished image would cover them for two seconds, then the previews resumed. Waiting between generations avoided it. The flash is the "reveal selected image" feature (#9217), which briefly hides the progress overlay so a mid-generation gallery click is visible. Its only guard against the auto-switch handoff was $isProgressImageResolving — a timing guard, and the timing loses: the auto-switch selection is dispatched only after onInvocationComplete's async DTO fetch, then waits for the thumbnail preload, and the next generation's first invocation_progress event slots into that window and resets the flag. By the time the handoff reaches the viewer it is indistinguishable from a user click, so the reveal fires over the live preview. Distinguish them by identity instead of timing: auto-switch records the image name in a small registry at dispatch, and the reveal effect consumes it on the selection's first render. Consumption happens on every rendered-image change, not only when the reveal conditions hold, because in the common (unraced) case the image renders with no progress showing and a leftover entry would suppress a genuine user selection of the same image later. Entries also expire after 30 seconds. Recording is unconditional but consumption requires the image to actually render, so a superseded auto-switch (two completions within one thumbnail-fetch window — routine with parallel multi-GPU sessions), a viewer unmounted by comparison mode, or a duplicate invocation_complete event would otherwise leave an immortal entry whose only future effect is to swallow a genuine click on that image — the very dead-click the reveal exists to prevent. The TTL is generous for the dispatch-to-render handoff it protects; expiring early merely readmits the 2-second flash on a very slow connection, which is the milder failure. The suppression branch still lowers $isTemporarilyShowingSelectedImage — the effect has already cancelled any running reveal's timer by that point, so returning with the atom raised would wedge the reveal on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): don't strand the viewer under the video progress overlay During a video render, the progress-preview overlay swallowed every gallery thumbnail click: the selection changed underneath, but the opaque overlay stayed on top, so nothing visibly happened until a tab switch remounted the viewer. Three causes, three fixes: - CurrentVideoPreview never implemented the temporary reveal that CurrentImagePreview got in #9217. Port it: clicking a thumbnail mid-render now lifts the overlay for 2 s so the click visibly lands, then the live preview returns. An actively-playing video is never re-covered (audio would keep running under an opaque overlay with unreachable controls); the overlay returns when the player closes. - The reveal's previous-item tracking was per-component, so any click that switched media type (image <-> video swaps the mounted preview component) reset it and the reveal was swallowed. The ref now lives in the shared ImageViewerContext; the image side is careful not to null it while a preload is still pending (adversarial-review finding: the mount run would otherwise erase the previous-video fact and kill the video->image reveal). - After completion, the "preview resolves into the final media" clear only fired from the final media's load callback. On a slow connection that lags far behind completion, and an errored <video> never fires it - stranding the overlay permanently. The video error handler now clears a pending resolve, and a 10 s failsafe in the context drops the illusion rather than strand the overlay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): tile concurrent session previews in the video viewer (multi-GPU) CurrentImagePreview tiles per-session previews when more than one render runs concurrently; CurrentVideoPreview only ever rendered the single shared latest preview, so parallel sessions overwrote each other's frames in place. Port the ProgressImageTiles branch, mirroring the image viewer exactly ($activeProgressData is already tracked per-session in the shared context). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(ui): resolve knip warnings * fix(ui): don't treat an auto-switch to a finished video as a user reveal CurrentVideoPreview treated any change of the selected video as a mid-render gallery click, so an auto-switch to a just-finished video hid the *next* render's live preview behind it for 2 seconds. The auto-switch selection is dispatched only after onInvocationComplete's DTO fetch resolves, so a quickly-started next render's first progress event can land ahead of it and reset $isProgressImageResolving. By the time the selection reaches the reveal effect it is indistinguishable by timing from a user click, which is why #9434 fixed the image side by identity instead: the name being auto-switched to is recorded at dispatch and consumed on the selection's first render. Port that to videos: addVideosToGallery records the video name, and the video reveal effect consumes it on every change of the rendered video (consuming unconditionally, or a leftover entry would swallow a genuine later click on the same video). The registry is keyed by gallery item name, which is polymorphic across images and videos, so autoSwitchedImages.ts and its test are byte-identical copies of #9434's — whichever branch merges second adds nothing. Reported by JPPhoto in review of #9475. * test(ui): pin the promoted session's preview against a stale resolve timer Covers JPPhoto's second review scenario on #9475 directly: two sessions generating, the one owning the shared preview finishes, and the other is left with no further progress event. Nothing armed by the completion — neither the resolve timeout nor the finished session's own late image load — may take the promoted session's preview down. * fix(ui): address review — deadline ownership handoff + duplicate-completion gallery work Two fixes from JPPhoto's review: 1. When the resolve deadline fired while other sessions were still active (multi-GPU), it only disarmed, leaving the shared progress atoms owned by the finished item. The surviving sessions' terminal events then saw a foreign owner and ignored them, stranding the opaque overlay on a stale preview after the last session ended. The deadline now promotes the most recently active session into the shared atoms, so its own terminal event clears or re-arms them normally. 2. Duplicate invocation_complete deliveries re-ran the gallery work, which double-counted optimistic board totals and re-recorded the auto-switch marker after it had been consumed — suppressing a later genuine gallery click on that image. The handler now tracks processed invocations itself (the shared dedupe map can't be used: the workflow coordinator pre-marks first-delivery events for non-active workflow items) and returns before any gallery work on a duplicate. The auto-switch registry additionally settles on every rendered-image change: a match drops all older entries, a miss clears the registry, so no stale entry survives past the next render to swallow a genuine click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ui): drop a comment reference to a symbol this tree doesn't have autoSwitchedImages.ts was copied from #9434, whose TTL comment points at that branch's PROGRESS_IMAGE_RESOLVE_TIMEOUT_MS. Main's equivalent is RESOLVE_TIMEOUT_MS in viewerProgressLifecycle, and the cross-reference adds nothing here either way. * fix(ui): close four holes an adversarial review found in the video reveal - The reveal effect could return with $isTemporarilyShowingSelectedImage still true after it had already cleared the running reveal's timer, wedging the overlay off for the rest of the render. Reachable on a plain mount under StrictMode: the double-invoked effect re-enters with the shared ref already holding this video's name and takes the previous-name early return. Every path out of the effect now lowers the atom; the image side gets the same treatment, where sharing the ref makes the shape reachable in principle too. - A rejected play() routed through the video error handler, which now ends a pending resolve illusion. A play rejection is not a load failure — the element is intact — so for an unattributable session it could cut short an illusion belonging to a different render. The toast path is split from the element-error path. - Playback running to its end left isPlaying true, so the overlay never came back and the live preview stayed hidden for the rest of the generation. onEnded now drops back to the idle still. - Concurrent completions (multi-GPU) recorded two auto-switched names but only the last selection ever rendered, orphaning the first entry for its full 30 s TTL — and that orphan, unlike the ones the TTL was written for, never self-heals, so the user's next click on that item was a dead click. consume() now drops every entry recorded before the one that rendered, since the selection moved on without those ever rendering. * fix(ui): address review round 2 — selection-scoped auto-switch marker + lookup-failure retry Two of JPPhoto's findings applied to the current head (his third, the deadline-promotion blocker, was fixed ahead of this round by the merge of main's #9389: the lifecycle's onTerminal hands the shared preview to the freshest surviving session on every terminal status, and its test 'keeps promoting through a chain of terminations' pins his exact promote-B/cancel-B/C-still-active sequence). 1. The auto-switch marker is now scoped to the selection it was recorded for, not keyed by image name with a TTL. A redux listener settles the marker on every action that moves the gallery selection (matched by state change, not action type, so new selection-writing reducers are covered automatically). An auto-switch that never renders — because the user clicked elsewhere first, even without a rendered-image change — is dropped the moment the selection moves on, so it can never swallow the user's later click on that image. At most one marker exists, and only while its selection stands, so the TTL and pending bound are gone. 2. A completion delivery whose DTO lookups all fail no longer poisons the dedupe key: the key is dropped so a re-delivery can redo the gallery work instead of being turned away as a duplicate of a delivery that never landed. Partial failures keep the key — the fetched DTOs' board totals and optimistic inserts were already dispatched, and a retry would double-count them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ui): cover the reveal suppression, and narrow the retry to gallery work An adversarial review of the round-3 changes found the mechanism sound but its verification hollow: deleting the auto-switch record(), or making the suppression branch unreachable, left all 1888 tests green. Both mutations remove the behavior this PR exists to deliver. - The reveal decision moves into a pure getSelectedItemRevealDecision, unit tested branch by branch. It answers 'reveal' or 'hide' and nothing else: the caller clears the running reveal's timer before asking, so a path that returned without writing the atom would strand the reveal on with no timer left to end it. That was reachable in the old shape via the previous-name early return. - onInvocationComplete gains tests that the auto-switched selection is actually marked (and is not when auto-switch is off), and that the video half of the retry condition is load-bearing, via a mixed image+video result. Two lower-severity findings from the same review: - A retry re-ran the whole handler, including two side effects that are global rather than per-event: the canvas processing flag and $lastProgressEvent. A re-delivery arriving after the user started another run would stop that run's spinner and blank its progress. The dedupe entry now records what is outstanding ('done' vs 'gallery-retryable') and a retry redoes only the gallery work. - The retry condition read "DTOs fetched", but two paths return before dispatching anything — a first intermediate image, and an all- intermediate video result. An event whose surviving output was intermediate therefore kept its key with nothing dispatched, so the lookup that failed alongside it could never be retried. The counts now mean "gallery work dispatched". Every fix above is pinned by a test that fails without it. * fix(ui): stop exporting a type nothing imports knip fails the frontend checks on it: SelectedItemRevealDecision is only ever the return type of the function declared beside it. * fix(ui): address review round 2 — selection-scoped marker, completion dedupe, reveal controller Two merge blockers and four findings from JPPhoto's review. 1. The name-keyed auto-switch registry is replaced with #9434's selection-scoped marker, taken verbatim (autoSwitchedImages.ts, the settle listener, and its tests are byte-identical between the two branches, so whichever merges second does so cleanly). A marker now survives only while the selection it was recorded for stands: a redux listener settles it on every selection move, so an auto-switch that never rendered can no longer swallow a later genuine click on the same item — the miss-leaves-marker-pending blocker. 2. Duplicate completion deliveries are deduped at the top of the handler (also ported from #9434, which grew the same guard for the image path), so a re-delivered video completion no longer re-records the marker or re-dispatches the auto-switch selection. A delivery whose DTO lookups all fail drops its dedupe key so a re-delivery can retry; partial failures keep it, since the fetched DTOs' gallery work already went out. addVideosToGallery keeps this branch's marker record for the video auto-switch — the one line #9434's copy of the file doesn't have. 3. The reveal sequencing moves out of the two preview components' effects into a shared controller (selectedItemReveal.ts) whose sequences are unit-tested, closing the three holes found in the inline version: - StrictMode's double-invoked mount effect killed every cross-media first reveal in development: the second run found the shared ref already holding the name the first run wrote. The controller remembers which item its own in-flight reveal is showing and re-arms instead of lowering. - A click landing inside a resolve window was dead forever: the ref advanced past it before the resolving guard ran. The controller leaves the ref and the marker untouched while resolving, so the click (or auto-switch) keeps its identity and is classified when the window ends. - Clearing the selection reset the ref to null, making the next click read as the viewer's first render. A cleared selection now moves the ref to a sentinel that is neither null nor a name, so the next selection — including the same item — reveals. 4. The metadata panel is gated on !isPlaying and !isTemporarilyShowingSelectedImage explicitly rather than riding on !withProgress, which both of those states turn off — it used to land exactly on top of the native controls or the just-revealed video. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): carry #9434's per-output retry, and unblock knip The completion-dedupe port in the previous commit took #9434's state at the time, which JPPhoto has since found two defects in — both now fixed there and brought across so the branches stay one implementation: - A duplicate arriving while the first delivery was still fetching was rejected outright, so when that delivery lost its output to a failed DTO lookup, the only event that could have retried it was already gone. Duplicates now await the in-flight delivery and become its retry; several waiters serialize into one. - The dedupe was event-wide, so a partial failure (plausible for image collections) permanently abandoned the output that failed while the rest landed. The entry now names the outputs whose lookup failed and a retry re-fetches only those, which is what makes recovering a partial failure safe from double-counting. The two handlers are now identical but for the video auto-switch marker record, which is this branch's. Also moves the three video-workflow query hooks out of the @knipignore block in videos.ts: they have call sites on this branch, so knip fails the frontend checks on the unused tags. Same change #9434 carries. * fix(ui): repair five defects an adversarial review found in the retry rework All five are in code this branch shares with #9434, and are fixed there too. - A result can name the same image twice (an image collection concatenates its inputs without deduping). Each occurrence was fetched and counted separately, inflating the board total — and because the retry set is keyed by name, a retry re-admitted the occurrence that had already landed and counted it again. Outputs are now fetched once per distinct name. - An intermediate output returned from the whole pass, abandoning siblings that belong in the gallery. That was survivable when the dedupe was event-wide; with per-output tracking those siblings are in nobody's missing set, so no re-delivery could ever recover them. Intermediates are now filtered, as the video path already did. - A retry re-ran the auto-switch, moving the user's selection (and possibly their board) long after they had chosen something else. A retry now lands the lost output and nothing more. - A throw inside the gallery work escaped as an unhandled rejection: both call sites discard this handler's promise. It is logged, and the outputs whose lookups failed are still recorded as retryable. - addBoardIdSelectedListener matched galleryViewChanged, which the auto-switch dispatches immediately before imageSelected. The probe it started woke on that very selection and re-selected the first name in a stale list, undoing the auto-switch — and the viewer then revealed that wrong image over the live preview, which is exactly the flash the marker exists to prevent. An explicit selection now cancels the probe. Each fix has a test that fails without it, including a real-store test for the listener. * fix(ui): don't put the overlay back over a reveal the user already earned A second adversarial pass, which independently confirmed the five fixes in the previous commit, found one more and three unpinned invariants. The controller lowered any in-flight reveal the moment a generation started resolving. A user clicking mid-render could therefore have their click covered again 200ms into its two seconds — by an unrelated session finishing — and, if that session produced no gallery output, stay covered until the 3s resolve backstop. A reveal already granted for the item on screen is now re-armed through the resolve window instead of lowered; one belonging to a different item still lowers. Newly pinned, each verified by mutation: the timer cancel that every run depends on (two live timers means the older one cuts the newer reveal short), the resolve window's lowering path, and that consuming one item's marker cannot spend another's — reachable whenever the rendered item lags a just-auto-switched selection. * fix(ui): repair five defects an adversarial review found in the retry rework Carried across from #9475, which shares this code — the two handlers stay one implementation. - A result can name the same image twice (an image collection concatenates its inputs without deduping). Each occurrence was fetched and counted separately, inflating the board total; and because the retry set is keyed by name, a retry re-admitted the occurrence that had already landed. Outputs are fetched once per distinct name. - An intermediate output returned from the whole pass, abandoning siblings that belong in the gallery. Survivable when the dedupe was event-wide; with per-output tracking those siblings are in nobody's missing set, so nothing could recover them. Intermediates are filtered, as the video path already did. - A retry re-ran the auto-switch, moving the user's selection (and possibly their board) long after they had chosen something else. - A throw inside the gallery work escaped as an unhandled rejection — both call sites discard this handler's promise. - addBoardIdSelectedListener matched galleryViewChanged, which the auto-switch dispatches immediately before imageSelected. The probe it started woke on that very selection and re-selected the first name in a stale list, undoing the auto-switch and revealing the wrong image over the live preview. An explicit selection now cancels the probe. Also pins that consuming one item's marker cannot spend another's. * fix(ui): cancel the board probe on any selection, not just imageSelected Two from JPPhoto's third round. The probe cancellation added last round matched imageSelected only, but the ordinary gallery paths — thumbnail clicks, shift/ctrl range selects, keyboard navigation — dispatch selectionChanged. A board or view change followed by any of those left the probe running, so it still replaced (or cleared) what the user had just picked, and the viewer then revealed that item over the live progress. The listener now matches the *change of active item* rather than a list of actions, which covers every writer, including any added later. Second: the reveal controller returned during a resolve window before recording that the selection had been cleared. Clearing and then re-selecting the same item inside that window left the ref on that item, so when the window ended the re-selection read as "nothing changed" and stayed hidden under the overlay. A clear is now recorded even while resolving — it has no pending identity to preserve, unlike the landed- but-unclassified render the deferral exists for. Both have tests that fail without the fix. * fix(ui): cancel the board probe on any selection, not just imageSelected Carried from #9475, where JPPhoto found it: the cancellation added last round matched imageSelected only, but thumbnail clicks, range selects and keyboard navigation dispatch selectionChanged. A board or view change followed by any of those left the probe running, so it still replaced or cleared what the user had just picked — and the auto-switch path this PR protects goes through exactly that window. The listener now matches the change of active item rather than a list of actions. * fix(ui): cancel the probe on any selection write, and stop losing outputs Two merge blockers from JPPhoto's fourth round. The board/view probe compared only the *active* item to decide whether a selection had landed under it. Narrowing a multi-selection, or re-picking the item already active, leaves that item unchanged while still being the user settling what they want — so the probe survived those and replaced their selection when it woke. It now compares the whole selection: the state is immutable, so a new array reference is exactly "the selection was written", and cancelling a probe more often than strictly necessary costs nothing. An output lost to a transient DTO lookup failure was only recovered if the server happened to re-deliver the completion event. Nothing re-emits one, so in practice the image was simply absent from the gallery and the board counts until something unrelated refetched them. A delivery that leaves outputs missing now refetches exactly those names at 1s, 3s and 9s, bounded — an output still missing after that is not coming back from a retry — with the entry left retryable so a re-delivery can still recover it for free. Each attempt takes the same path as a re-delivery, so only missing names are fetched, landed outputs are untouched, and the global side effects are not re-run; a new pass supersedes the refetch already queued, so duplicates cannot run two chains. This is the retry work from the follow-up branch, brought down to where the blocker was raised. Tests come with it, including the sequences from the review. * fix(ui): cancel the probe on any selection write, and stop losing outputs Carried from #9475, where JPPhoto raised both as merge blockers; this branch shares the listener and the completion handler. - The board/view probe compared only the active item, so narrowing a multi-selection or re-picking the item already active left it running to overwrite the user's selection. It now compares the whole selection. - An output lost to a transient DTO lookup failure was only recovered if the server happened to re-deliver the completion event, which nothing makes it do. Missing outputs are now refetched at 1s, 3s and 9s, bounded, each attempt taking the same path as a re-delivery so landed outputs are untouched and the global side effects are not re-run. * test(ui): pin the reveal's connection to the overlay atom The merge resolution replaced #9434's inlined reveal with this branch's controller, and rewrote the wiring test that went with it. That test carried the only assertion on either branch that the component writes $isTemporarilyShowingSelectedImage -- it matched the literal hide path the inlined version had -- and the rewrite dropped it. Nothing else covers it. selectedItemReveal.test.ts substitutes its own setRevealed, so the controller tests are structurally incapable of observing the atom, and CurrentVideoPreview's assertions are all on the read side (withProgress, the metadata gate). Neither component is ever mounted: this directory has no DOM test environment. An adversarial review of the merge proved the gap by replacing setRevealed with a no-op in both previews: all 26 assertions across the two wiring tests still passed, and so did the full suite, with the reveal completely dead -- a mid-render gallery click doing nothing, which is the bug both PRs exist to fix. Both tests now fail against that mutation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): don't let retries outlive their session, or swallow the first click Two merge blockers and two findings from JPPhoto's fifth round. Scheduled refetches survived socket and auth teardown. The timers close over an event from the session that scheduled them but dispatch into whatever store is current when they fire, so a logout, account switch or reconnect inside the 13s window would fetch the old session's output and insert it into the new session's gallery and board caches. setEventListeners now returns a disposer, and useSocketIO calls it before disconnecting. The reveal suppressed the first item the viewer ever rendered, on the grounds that the viewer opening onto an existing selection is not a click. But a viewer sitting empty while a generation runs is a state the user has been shown, and their first click there is a click like any other — it was landing behind the overlay. An empty selection now records the cleared-selection sentinel whether or not anything rendered before, so that click reveals while the open-onto-a-selection render still does not. Also, since this is the third round it has come up: the video reveal no longer starts its two seconds at mount. preload="metadata" and the near-zero seek do not prove a frame exists, so the reveal could run out over a black element and then re-cover it. The controller now holds the claim until the item reports a decoded frame (onLoadedData), bounded by a 1s grace so media that never loads still makes the click land. Readiness is reported as *which* item has painted rather than a boolean, because a boolean would be reset from a different effect than the one that reads it. And retry success no longer bumps cached board totals: seconds after the fact another refresh may already have inserted the output, and unlike the name-list insert those increments do not dedupe. A retry now asks the server for the affected boards instead. * fix(ui): end a completion handler's session properly, and restore the socket suite Restores services/events/setEventListeners.test.ts, which I destroyed in b874b90: I wrote the file without checking it existed, and its 644 lines of executable socket coverage — workflow invalidation, queue cancellation, own/foreign routing, cross-user isolation — went with it, replaced by two source-string checks. The suite is back, and the teardown it needed is now an executable test in it rather than a grep: the mocked handler carries a dispose(), and the disposer setEventListeners returns is asserted to call it. The blocker behind that test: disposal only cleared queued timers. A DTO request already in flight came back afterwards, dispatched into whatever store had replaced the old one, and scheduled fresh retries against it — so a logout or account switch during a lookup could put one user's output in the next user's gallery. The handler now knows it has been disposed and checks after every await: nothing already fetched is dispatched, nothing new is scheduled, and an event delivered after teardown does nothing at all. Also, a duplicate delivery restarted the backoff from one second, so a stream of duplicates during an outage could keep starting fresh chains — the bound existed but nothing was bounded by it. The attempt count lives on the retry state now, and a duplicate resumes the chain where it had got to. * fix(ui): close the disposal escapes a self-review found before the next round A fresh adversarial pass over 042350c, done deliberately before the reviewer's next round, converged on his known categories. - An in-flight delivery disposed mid-fetch still ran $lastProgressEvent.set(null) after the gallery guards returned. That store is module-global across handler sessions, so a stale delivery resolving after a logout or account switch blanked the progress event the new session had put there. Guarded, and the disposal test now asserts the store is never touched. - The DTO fetch loops kept issuing lookups after disposal — requests under the replacement session's credentials, cache writes into its store. Both loops now stop. - Three load-bearing pieces had no test that failed without them, and test-insensitivity is where review rounds keep coming from: - the video path's post-fetch disposal guard (every disposal test used image events; its mutation survived the whole suite); - the reveal controller's resolve-window hold for an unpainted claim (deleting it silently re-created the swallowed-click failure); - the settle-listener registration in store.ts (every listener test builds its own store; deleting the registration failed nothing). Each now has a test pinned via mutation from a verified cwd — two of this session's mutation runs previously "passed" by running against a path that did not exist. * test(ui): mount the reveal wiring in a real DOM instead of grepping for it JPPhoto's round-7 finding, and a fair one: the preview components' tests inspected source text, so a wiring or ordering regression could leave the overlay or the reveal broken with every test green. The package had no DOM test environment at all. The wiring the two components shared — one controller per mount, run on every input change with a cleanup that cancels only the timer, the flag lowered on unmount — moves into useSelectedItemReveal, and the video's painted-name readiness into usePaintedItemName beside it. Both components become a hook call; the hook is mounted under happy-dom (new dev dependency) with real effect lifecycles and mutation-verified coverage for exactly the things source text cannot see: - the image -> video component swap over the shared ref, including the outgoing component's timer being cancelled before it can cut the incoming reveal short; - unmount lowering the flag with no timer left to re-raise it; - StrictMode's double-invoked effects; - readiness driven by a real <video> element's loadeddata event, and reset when the element is swapped for another video. The remaining source-text assertions shrink to what they are good for: pinning that the components actually call the tested hook, with the right readiness expression on each path. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com> Co-authored-by: JPPhoto <jpollack@jpollackphoto.com>
Summary
I have been working over a flaky Internet connection for the past few days, connecting to my home InvokeAI server over an internet connection that has 5-15% packet loss, depending on congestion. In this environment I observed a long delay between the final VAE decode finishing and the viewer replacing the last progress preview with the finished image. While testing the fix I also hit a related race: starting a new generation soon after the previous one finished made the viewer flash the previous generation's finished image over the new generation's live previews for two seconds.
Note
This PR originally also fixed the progress overlay sticking permanently after a socket drop, via a deferred-clear backstop and socket-lifecycle resets. #9389 has since merged a more complete version of that machinery (
viewerProgressLifecycle.ts: a resolve timeout, identity-gated load attribution, successor handoff between multi-GPU sessions, and disconnect/queue-cleared resets), so the merge frommainadopts it wholesale and this PR's superseded backstop was removed. What remains here is complementary and sits on top of the lifecycle.Three fixes remain in this PR:
1. Why the reveal was slow
CurrentImagePreviewgated rendering behind an off-DOM preload ofimageDTO.image_url— the full-resolution PNG (/api/v1/images/i/{name}/full, often several MB).imageToRenderwas not set until that completed, and only the resultingonLoadcleared the overlay. So the stale latent preview stayed on screen for the entire full-resolution download.A 256px WEBP thumbnail is already generated for every image and was never used to shorten that wait — and it is typically higher resolution than the latent preview it replaces. The reveal is now gated on the thumbnail;
DndImagerenders it through Chakra's existingfallbackSrcand swaps the full image in, in place, once it finishes. The preload settles on success or error and reports through the lifecycle's identity-gatedonLoadImage(sessionId), so a failed load cannot wedge the overlay and a late-settling thumbnail from an earlier session cannot cut a different session's resolve illusion short.The preload also used the raw
imageDTO.image_urlwhileDndImagerequestsuseMediaUrl(imageDTO.image_url), which appends?media_cookie_version=N(bumped to 1 on every authenticated app load). Different key, so the same bytes were fetched twice. Measured against a local server: 2 requests when the URLs differ, 1 when they match. Note the reuse is the document's list of available images, which is keyed by URL and is not the HTTP cache — so it still holds in multiuser mode, where images are servedCache-Control: private, no-store.2. Why the previous image flashed over the next generation's previews
Generating again quickly after a completion produced: new previews appear, then the previous generation's finished image covers them for two seconds, then the previews resume. Waiting a few seconds between generations avoided it.
The flash is the "reveal selected image" feature from #9217, which briefly hides the progress overlay when the rendered gallery image changes mid-generation, so a user's gallery click is not invisible under the opaque overlay. Its only guard against the auto-switch handoff was
$isProgressImageResolving— a timing guard, and the timing loses. The auto-switch selection is dispatched only afteronInvocationComplete's async DTO fetch, then waits for the thumbnail preload before rendering; the next generation's firstinvocation_progressevent slots into that window and resets the flag (it must — the lifecycle'srecordProgresscancels the previous item's pending resolve so its timeout cannot blank the new preview). By the time the handoff reaches the viewer it is indistinguishable from a user click, so the reveal fires over the live preview.The fix distinguishes the two by identity instead of timing: auto-switch records the image name in a small registry (
features/gallery/store/autoSwitchedImages.ts) at dispatch, and the reveal effect consumes the entry on the selection's first render — a consumed entry suppresses the reveal, while genuine gallery clicks (never recorded) reveal exactly as before.The marker is scoped to the selection it was recorded for (JPPhoto's round-2 suggestion), not keyed by name with a TTL: a redux listener (
autoSwitchedSelection.ts) settles it on every action that moves the gallery selection — matched by state change rather than action type, so a future selection-writing reducer can't escape it. An auto-switch that never renders because the user clicked elsewhere first is dropped the moment the selection moves on (even when that click causes no rendered-image change), so it can never swallow the user's later click on that image. At most one marker exists, and only while its selection stands, which is why no expiry or bound is needed.3. Duplicate
invocation_completedeliveries re-ran the gallery work (JPPhoto's review round)A duplicate completion double-counted the optimistic board totals and re-dispatched the auto-switch. The handler now tracks processed invocations itself and returns before any gallery work on a duplicate, marking the key before the DTO-fetch await so a duplicate landing mid-flight is rejected too. The shared
completedInvocationKeysByItemIdmap could not be used for this: the workflow coordinator pre-marks first-delivery events for non-active workflow items, so keying the early return off it would have skipped gallery work for legitimate queued workflow completions.Round 2 refined the failure path:
getImageDTOSafeswallows fetch errors and returns null, indistinguishable from "no image output", so a transiently failed lookup used to lose the image and poison the dedupe key against the re-delivery that could have fixed it. The DTO extractors now report lookup failures, and the key is dropped when a lookup failed and nothing was fetched — a re-delivery then redoes the gallery work. A partial failure deliberately keeps the key: the fetched DTOs' board totals and optimistic inserts were already dispatched, and a retry would double-count them.JPPhoto's other round-1 finding — the resolve deadline stranding the overlay when other multi-GPU sessions were still active — was fixed here first and is now superseded by #9389's
onTerminalsuccessor handoff onmain, which prevents the stranded-owner state from arising at all; the merge adopts that and drops this PR's version.Tests
The marker has unit tests for its record/settle/consume semantics, and the redux listener is tested against the real gallery reducer (including the reviewer's reselect-without-a-render-change dead-click sequence, and that actions which don't move the selection leave the marker alone). The duplicate-completion handling has tests for exactly-once processing, a duplicate landing mid-DTO-fetch, the transient-failure retry (first lookup fails, re-delivery redoes the work), the no-retry-on-partial-failure gate, and distinct invocations of the same queue item.
CurrentImagePreview.test.tspins the thumbnail gating, the settle-on-error path with session-id attribution, and the marker consume placement (wiring checks — this directory has no DOM test environment). The lifecycle behavior itself is covered by #9389'sviewerProgressLifecycle.test.ts, extended here with a promotion-chain test pinning the reviewer's promote-B/cancel-B/C-still-active sequence.Verification
For the current head:
lint:tsc,lint:eslint,lint:prettier,lint:dpdm,lint:knipall clean; full suite 1912 tests passing (the production build left to CI).Not yet exercised against a live backend. End-to-end checks still worth doing before merge:
/fullis requested once, not twice).Follow-up (not in this PR)
StagingArea/state.ts) still hides its progress image only onceonImageLoadedfires fromDndImage, giving it a load-error wedge. It partially self-heals via thelistAllQueueItemsrefetch, so it is left out here to keep the diff reviewable.RESOLVE_TIMEOUT_MSis 3 s. With the thumbnail gate the normal resolve is well under that, but on a badly lossy connection a hung (not failed) thumbnail fetch longer than 3 s ends the illusion early — a brief flicker through the previously selected image, where this PR's pre-merge backstop waited 30 s. Whether to stretch the lifecycle timeout now that the load it waits on is a ~20 KB thumbnail is a small product call, left for a follow-up.🤖 Generated with Claude Code