| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
|
Rebased onto the consolidated #9475, which now contains #9434 as well — so this branch picks up Two test adjustments came with the merge, both in the wiring tests:
Ready for review after #9475. |
Sorry, something went wrong.
…nvoke-ai#9475 invoke-ai#9475 was squash-merged (e431d24), orphaning this stacked branch's history, so this is a transplant rather than a rebase: one commit that re-applies the branch's semantic delta on top of main's merged state — including the parts of invoke-ai#9475 this branch predated (session disposal, the restored socket suite, and the hook + happy-dom test architecture from its final round). What this branch changes, restated against main: - Selections carry identity instead of being inferred by comparing names. $gallerySelection publishes {name, generation, isAutoSwitch} from one store listener; the auto-switch mark is set immediately before the handoff dispatches its selection and spent by that selection alone. The name-keyed marker module and its settle listener are deleted. - The reveal is a state machine (idle | deferred | awaiting-media | revealing) owned by the viewer context — one instance for both preview components, so a click that switches media type is just another selection. The shared previous-item ref and its cleared-selection sentinel are gone. Re-picking the item already on screen now reveals: it changes no state and no rendered name, only the generation, which the old comparison could not see. - The machine owns the revealed flag and its timers exclusively; the provider settles selections that land while neither preview is mounted and resets the machine on teardown. - useSelectedItemReveal keeps its mounted happy-dom coverage, now asserting the machine wiring: one reveal running across a component swap on its original clock, attach making the provider's settle a no-op while mounted, the repeat-click reveal, media readiness from a real <video> loadeddata, and provider teardown leaving no timer. Every wiring guarantee above is mutation-verified. The bounded-refetch work this branch once carried merged with invoke-ai#9475 and is inherited, not re-introduced.
There was a problem hiding this comment.
To examine:
invokeai/frontend/web/src/features/gallery/components/ImageViewer/selectedItemReveal.ts:125-134: Grace timer starts before selected media exists, then reveals unconditionally. Slow DTO/media load shows stale/blank content; if it exceeds 3s, late item stays covered. Effect: click becomes wrong or invisible. Likelihood: Medium. Test: delay selected render past 3000ms, then assert reveal still occurs.
invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/gallerySelectionSource.ts:20-22: Treats every selectionChanged as a viewer selection. Ctrl-click removing a non-active item leaves the displayed item unchanged but still bumps generation and reveals it. Effect: progress overlay flashes off for unrelated multi-select bookkeeping. Likelihood: Medium. Test: active b, selection [a,b], dispatch [b] and assert no reveal.
Suggestions:
Instead of starting the media grace timer on selection, start it only once the selected item is rendered; cancel when generation changes.
Consider separating active-item changes from multi-selection mutations.
Sorry, something went wrong.
…election mutations as picks Two review findings from JPPhoto: - The media grace deadline used to run from the moment the selection landed, so a slow DTO/render lifted the overlay onto whatever the component was still showing (the previous item, or nothing), and an item that took longer than the grace to arrive then stayed covered, its reveal already spent. The awaiting-media state now enters without a timer; the deadline is armed the first time the component actually renders the claimed item without a painted frame, so it bounds only the media wait it was designed for (failed loads, undecodable codecs). Until the item renders, the claim simply stays outstanding — cancelled as before by any newer selection or by the overlay going away. - The gallery selection source counted every selectionChanged dispatch as the user picking something, so a multi-select mutation that leaves the active item in place — ctrl-clicking a non-active item off the selection — bumped the generation and flashed the overlay off for a gesture aimed at a different item. The two kinds of dispatch are indistinguishable at the listener (ctrl-deselecting `a` from [a, b] and plain-clicking `b` produce identical actions and transitions), so the separation happens where the gesture is known: plain clicks in the grid now dispatch imageSelected (reducer-equivalent for a single name), and selectionChanged is a pure mutation the listener no longer matches — its mutations count only when they move the active item. Both behaviors are covered by new machine/listener tests, each verified to fail against the code it fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both confirmed and fixed in a21dec2 — thank you, both were real. Grace timer vs. slow render. Exactly as you described: the deadline was anchored to the selection, so a slow DTO/render meant the timeout lifted the overlay onto stale or blank content, and the reveal was already spent when the item finally arrived. I took your suggestion: awaiting-media now enters with no timer, and the grace is armed the first time the component actually renders the claimed item without a painted frame — so it bounds only the wait it was designed for (failed loads, undecodable codecs). Until the item renders, the claim just stays outstanding; it's still cancelled by any newer selection (every transition clears the timer) or by the overlay going away. Your test is in as does not run the grace against an item that has not rendered yet: item renders long after the deadline would have expired, and the reveal still occurs. One deliberate wrinkle: the armed timer is not cancelled if a later sync disagrees on renderedItemName — during an image↔video swap both previews are briefly mounted and sync alternately, and the stale component must not kill the deadline the matching one armed. selectionChanged as a pick. Also as described. The listener alone can't fully separate the two gestures — ctrl-clicking a off [a, b] and plain-clicking b to collapse the selection produce byte-identical actions and state transitions — so I separated them where the gesture is known, per your suggestion: plain clicks in the grid now dispatch imageSelected (reducer-equivalent for a single name), and selectionChanged is a pure multi-selection mutation that the listener no longer matches. A mutation that moves the active item (ctrl-click adding an item, shift-range) is still published by the change-of-active-item clause; one that leaves it in place (your ctrl-deselect case) no longer bumps the generation. Your test is in verbatim: seed [a, b], dispatch [b], generation unchanged. Both new tests were verified to fail against the code they fix. One residual worth naming so it doesn't look overlooked: ctrl-clicking the active item off a multi-selection ([a, b] with b active, ctrl-click b) still reveals — the active item moves to a, so the change-of-active-item clause publishes it. That's unchanged from before this round, and I've left it: the viewer's displayed item genuinely changes there, so showing the user what's now active seems right — but the deselect branch does know the gesture, so if you'd rather suppress that one too it's a small follow-up. |
Sorry, something went wrong.
There was a problem hiding this comment.
Needs addressing:
Corner/impossible cases that can safely be ignored:
Suggestions:
Instead of treating all imageSelected actions as picks, exclude internal delete normalization or add explicit user-pick source.
Consider keying media readiness by src or DOM element generation, not filename.
Sorry, something went wrong.
The delete flow's survivor branch — the displayed item outlived a deletion that took other items out of the multi-selection — dispatched `imageSelected(lastSelected)`. That leaves exactly the state it wants, but `imageSelected` is the action that means "the user picked this", and the viewer answers a pick by lifting a running generation's progress overlay off the item for two seconds. Deleting some other item flashed a stale result over a live render. Dispatch `selectionChanged([lastSelected])` instead: same state, but the mutation action, which the selection-source listener publishes only when it moves the active item. The advance branch keeps `imageSelected` — there the viewer genuinely moves to a different item, and revealing it is the point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
…er pick Same class as the delete-flow pruning: the probe selects an item *for* the user after a board or view change, but dispatched `imageSelected`, the action that means "the user asked to see this". NoBoardBoard re-dispatches boardIdSelected even when its board is already selected, so the probe can land on the item already on screen — and as a pick that lifts a running generation's progress overlay off it for two seconds. `selectionChanged` leaves identical state and is published only when it moves the active item, so a probe that genuinely changes what the viewer shows still reveals; one that re-selects what is already displayed no longer does. The test also covers the probe's success path, which had none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Adversarial review of the previous commit: switching the survivor branch to `selectionChanged` is necessary but not sufficient. The branch collapsed the selection onto `lastSelected`, snapshotted before the delete request. If the user selects something else while the request is in flight, that collapse discards their pick *and* moves the active item back — which the listener publishes under its change-of-active-item clause, so the overlay flashes anyway, for a gesture aimed at a third item. Filter the deleted names out of the live selection instead, falling back to the surviving displayed item when everything selected since has been deleted. The ordinary (non-racing) case is unchanged. Also strengthens both delete suites, which after the previous commit pinned only the item left displayed and passed against a survivor branch that pruned nothing at all, and narrows the doc comment that overstated the action choice as sufficient on its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Adversarial review of the board-probe commit: guarding the probe's *action* only helps when it happens to re-select the item already displayed. The probe selects the board's first item unconditionally, so re-running it for a navigation that changed nothing throws the user's selection away and moves the viewer — which publishes as a change of active item and reveals over a running generation anyway. Guard the two callers that could re-dispatch for no change: NoBoardBoard (GalleryBoard and VirtualBoardItem have always guarded this; it never did) and the gallery's view tabs. NoBoardBoard's click actions move to their own module so they can be unit-tested — the component pulls in the dnd stack, which a plain unit test cannot import. Also from the review, on the previous commit: - both delete suites now assert *every* selection write, not just the first; a stray trailing dispatch — what the user would actually end up looking at — passed unnoticed, as did dropping the fallback from the video modal. - adds the two paths neither suite covered: the video fallback, and a non-racing three-item multi-selection. - the survivor branch's fallback cannot honour "don't move the active item" and can still name an item removed by an overlapping delete; both are pre-existing, and the comments now say so rather than claiming otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
…t probe Replaces the click guards from the previous commit. Guarding each caller was both too much and too little: too much, because it swallowed the click outright, and clicking the board is exactly how a user recovers when the selection is empty — after deleting the last item, after turning date boards off (which resets the board and clears the selection without dispatching boardIdSelected), or after the probe's own give-up. Too little, because the guards read render-time snapshots, so a click racing a store update could be dropped, and every future caller would have to remember the rule. The probe itself knows the answer: if what the viewer is showing is still in the list it just fetched, the board or view did not really change and there is nothing to fix, so it writes nothing. If the selection is empty or gone from the list, it selects as before. One place, live state, all callers. Also fixes the give-up path this exposed: `condition` only re-evaluates its predicate when an action is dispatched, so an already-fulfilled list with a quiet store gave no wake-up at all — the probe sat through its 5 s deadline and cleared a selection it should have kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
With the probe now skipping writes that change nothing, every write it does make moves the displayed item and would publish under either action — so the suite passed with the probe dispatching `imageSelected` again. An empty board with nothing selected is the one write that changes nothing and would still publish as a pick; that case is now covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Adversarial review of the previous two commits found the "is the displayed item in the new list?" test wrong in two ways. It skips genuine board switches. A virtual date board's query args drop `board_id` and filter on `created_date` alone, so its list is a superset of every board's items for that day: switching from a real board to a date board left the viewer on the old board's item, with the grid scrolled to it instead of to the newest — and a cross-board multi-selection survived the switch, so bulk actions then operated on items not visible in the grid. And the short-circuit that skipped the query wait for an already-fulfilled list made the effect run to completion synchronously inside the dispatch that started it. That let the probe's write land between `markNextSelectionAutoSwitched()` and the auto-switch's own `imageSelected`, consuming the marker so the auto-switch registered as a user pick — the exact flash the reveal machine exists to prevent. Only an incidental cache invalidation kept it from firing today. Reverted; the pre-existing quiet-store give-up it was fixing is reported to the reviewer instead. The question the probe actually wants is whether the board or view changed at all, which `getOriginalState()` answers exactly, before any await — so a click that should do nothing cannot reach the give-up branch either. An empty selection still probes, because that is the case where the user needs the probe to pick for them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
… a probe Adversarial review of the previous commit: "the selection is non-empty" is not "the user has something valid on screen". A search term — or starredFirst, orderDir, the archived-boards toggle — narrows the list without starting a probe, so the selection can be absent from what the grid shows. Clicking the board, which is how the user gets out of that, was being swallowed. The same gap stranded the viewer on an item from a board that had just been deleted: the app dispatches `boardIdSelected` then `galleryViewChanged` back to back there (and again when an upload targets another board), and the second of each pair is a no-op, so it skipped while the first board change was still in flight. Ask the fuller question instead: did this navigation change the board or view, and is the displayed item in the list that board is already showing? An uncached list falls through and probes as before. Read synchronously off the cache, so a click that should do nothing never enters the query wait, where a quiet store can expire the 5s deadline and clear the selection. The check also moves above `cancelActiveListeners()`. That is defensive rather than load-bearing — with membership required, a skip can only happen when the displayed item is valid for the list now on screen, so cancelling first would not actually strand anything, and no test can tell the two orders apart — but a navigation that changes nothing has no business cancelling the probe of one that did. Adds the coverage the review found missing: the cancel-then-return pair, the view-tab half of the check (a mutation neutering it passed the whole suite), the filtered-out-selection recovery, and a switch whose destination list is cached *before* the click — the case that distinguishes comparing against the previous state from a bare membership test. The empty-selection test no longer leans on a synthetic tick to wake the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
Five attempts, four adversarial reviews, a new regression each time — the auto-select probe's interaction with RTK Query's cache, `condition`'s wake-on-dispatch semantics, and the action pairs the app dispatches around it is subtler than it looks from the call site. The last version could still be provoked into the opposite failure: a click that changed nothing, on a selection the current list does not contain, fell through to a query wait that nothing would wake, and the 5 s give-up cleared the selection outright. A cold cache entry — which toggling starred-first or order direction produces instantly — let the same click take a good selection away and lift the progress overlay, the very flash this was meant to stop. None of this is what the review that started this round was about, and all of it predates the PR. Keeping only the part that does belong: the probe writes with the mutation action, so a re-run that lands back on the item already displayed no longer announces itself as a user pick. Filed the rest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
|
Both confirmed. Fixed, though the delete-flow one took two passes and the second finding sent me Delete-pruning dispatches a pick it doesn't meanReal, and it traces exactly as you described: selection [a, b] with b displayed, hover-delete The first fix used the round-1 vocabulary rather than a new mechanism: that branch prunes a
So it now filters the deleted names out of the live selection, falling back to the surviving const survivors = getState().gallery.selection.filter((name) => !deletedNames.has(name));
dispatch(selectionChanged(survivors.length > 0 ? survivors : [lastSelected]));Correcting a claim I made in that commit message: this is not behaviour-neutral outside the Two holes in that branch I did not close, both pre-existing and both now described in the
The generalisation, and the part I backed outSweeping every imageSelected dispatch for "rewrites the selection without the user having asked I also tried to fix the underlying behaviour, which is worse than the flash: the probe selects
None of that is what you flagged and all of it predates this PR, so I've pulled it out to #9535 The rule itself is written into gallerySelectionSource's doc comment, including the half my first Same-name video src refreshConfirmed real, and left alone — flagging why in case you disagree. It predates this PR: TestsBoth delete suites now assert every selection write and its whole payload, not just the first. Full frontend suite green (1990 tests), types/lint/prettier clean. |
Sorry, something went wrong.
CI's `tsc --noEmit` covers the whole project; vitest's typecheck pass only looks at `*.test-d.ts`, so this never showed up locally. The test store holds just the two slices the listener needs, and the selector is typed against the full RootState. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRgbe8C7ZfunjxRuFZxhPw
There was a problem hiding this comment.
LGTM!
Sorry, something went wrong.
OMG, thank you! It's been a long road. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Summary
Follow-up to #9475 (merged), delivering the restructuring suggested across its review rounds: reveal coordination stops being shared mutable state plus timing guards, and becomes an item-owned state machine fed by explicit selection identity.
Selections carry identity
$gallerySelection publishes {name, generation, isAutoSwitch} from one store listener. The generation advances on every selection dispatch; the auto-switch mark is set immediately before the handoff dispatches its selection and is spent by that selection alone. The name-keyed marker module and its settle listener are deleted — a marker keyed by item name cannot distinguish "the auto-switch landed" from "the user picked that item later", which is where several review rounds' defects came from.
The reveal is a state machine
idle | deferred | awaiting-media | revealing, owned by the viewer context — one instance for both preview components, so a click that switches media type is just another selection. The shared previous-item ref and its cleared-selection sentinel are gone. Three behaviors fall out:
Coverage
The mounted happy-dom suite from #9475's final round carries over, re-pointed at the machine wiring: one reveal running across an image→video component swap on its original clock, attach making the provider's settle a no-op while mounted, the repeat-click reveal, readiness from a real <video> loadeddata event (reset on element swap), StrictMode double-invocation, and provider teardown leaving no timer. The machine's sequencing has its own unit suite. Every wiring guarantee is mutation-verified.
History note
This was stacked on #9475; the squash merge orphaned that history, so the branch is a single transplanted commit on main. The bounded output refetch this branch once introduced merged with #9475 and is inherited here, not re-introduced.