fix(preview): stamp the window's anchor, not the row the cursor is on - #175
Open
lstein wants to merge 3 commits into
Open
fix(preview): stamp the window's anchor, not the row the cursor is on#175lstein wants to merge 3 commits into
lstein wants to merge 3 commits into
Conversation
lstein
requested review from
JPPhoto,
Pfannkuchensack and
blessedcoolant
as code owners
August 28, 2026 16:21
lstein
force-pushed
the
fix/preview-recents-in-deep-window
branch
from
August 28, 2026 18:40
6415746 to
019fa7a
Compare
A deep reveal from the image map anchors Preview's infinite window mid-board, and such a window is one-way by design: it cannot grow upward past its anchor, because the grid shares the cache entry and rows spliced in above its viewport would shift the content under the user (`getPreviousPageParam` in gallery/data/queries.ts). That constraint decides everything below, and the previous attempts at this bug were all ways of fighting it. The page stamped on a selection is, for an infinite window, the anchor of the window that holds it. That is already what a grid click stamps — the grid's own page, whatever row was clicked. Preview stamped the row instead, and treated the stamp as sticky per query identity to keep the window from following the cursor. Both halves were wrong: - Read live, a row-stamped page rolls the window forward at every page boundary. The old entry is discarded (anchored windows are not kept in cache) and, the window being one-way, everything the user just walked through is unreachable. - Held sticky, the anchor outlived every selection made elsewhere. A grid click on the newest image at the top of the board — same board, view, order, mode, search — restamped page 0 and Preview ignored it, querying rows 1800+ for a selection at row 0. Stamping the anchor removes the need for stickiness: Preview's own steps never change the stamp, so it can be read live, and the only things that change it are selections made elsewhere, each of which names the window that holds the new selection. Nothing is ever released, because the stamp is the selection's window and changes with the selection. The boundary continuation stamps against the data it just fetched — the item is not in the pages the callback closed over, and a lookup there would read it as an item the window does not hold. An item the window does not hold is stamped at the top of the listing: a recent the listing has not caught up with lives there, and the base window's reach is the best guess for the compare slot's image. Reusing the deep page filed a top-of-board image under row 1800, which is the bug this branch was opened for. Recents are excluded from Preview's list when PREVIEW's window is mid-board — the only value that describes the list being merged into. The grid's page belongs to whatever board the grid is showing, and the stamped page alone is not the window either (a reveal within the base window's reach stamps a small page and the window stays at 0). Six tests, each verified failing against exactly the behaviour it replaces and against nothing else. The harness now honours an infinite window's offset, floors an anchored window at its anchor as production does, keeps one QueryClient across a re-render so cached pages survive as they do in production, and commits Preview's own selections back into the values so a multi-step walk is a walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SsiLhQJTBHJsEr78dTqdQG
lstein
force-pushed
the
fix/preview-recents-in-deep-window
branch
from
August 28, 2026 22:48
d053939 to
7aef735
Compare
The previous commit made the stamped page mean "the anchor of the window holding the selection" and read it live. That holds as long as every writer of the stamp names the window the item was selected in. Three did not: they selected without a page, which the reducer fills from the grid's page — the GRID's window, not Preview's. When the two had diverged (a result landing on the board releases the grid's anchor; so does a reload) a deep selection was filed at page 0, outside the window it came from: forward arrow dead, back arrow a 1800-row teleport. - The deletion successor is chosen from the host's own list. The action context Preview hands to image actions now carries the host's stamp (`getItemSelectionPage`), and the successor is selected with it — in the single-selection path and in the multi-selection one a partial failure takes, which gains a page for the purpose. "Open in preview" from Preview's own menu goes the same way. A host that provides no page keeps the exact call it made before. - Swapping the compare image in and back out. Swapping in stamps the top of the listing — the window does not hold the compare image, and a recent lives there — which moves the window to the top, so the deep image now in the compare slot is not held either, and swapping back could only guess. It no longer guesses: Preview remembers the page the item was selected at when it put it in the slot, and a swap back restores the window it was navigated in. Paginated boundary crossings now stamp the page landed on rather than the one departed from. That is a change from main, where the continuation looked the item up in data it did not have and fell back to the departed page; the sticky anchor hid it in-session, and it only showed on remount. It is recorded here rather than claimed untouched. Five tests, each verified failing against the variant it guards: the context's page for an item in the window and for a recent, the swap round trip, the successor and the retained multi-selection carrying the page, and the reducer stamping an explicit page into the navigation query already on the selection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SsiLhQJTBHJsEr78dTqdQG
…ed in A remembered page names a window of ONE query. Keyed on the item alone, the memo restored a deep page after the user had moved to another board and clicked there: the swap back stamped page 30 into that board's query, and Preview anchored the wrong listing 1800 rows down around an image that is not in it — an empty one-way window, both arrows dead. The memo now records the navigation query key too, and is honoured only when it matches. Three coverage gaps from the same review, each now pinned by a test that fails against the mutation it was found with: the multi-selection reducer preserving the query already on the selection rather than rebuilding it from the grid (the test now moves the grid to another board and search first); "open in preview" carrying the host's page; and the action context stamping the window anchor for an item on a later page of the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SsiLhQJTBHJsEr78dTqdQG
lstein
force-pushed
the
fix/preview-recents-in-deep-window
branch
from
August 28, 2026 23:29
aa3e4ea to
562b0b0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reveal an image from the image map that sits deeper than the base window can reach — board index ≥ 600 — and Preview anchors its navigation window at that image's page, ~1800 rows down the board. From there, two things went wrong that the grid does not do: today's generations were date-sorted on top of that slice, so the arrows walked onto images the grid was not showing; and stepping onto one filed a brand-new image under board page 30.
The constraint that decides the design
An anchored infinite window is one-way by design: it cannot grow upward past its anchor, because the grid shares the cache entry and rows spliced in above its viewport would shift the content under the user (
getPreviousPageParamingallery/data/queries.ts). Every earlier version of this PR was a way of fighting that constraint, and each one traded the bug it fixed for a worse one.The fix
The page stamped on a selection is, for an infinite window, the anchor of the window that holds it. That is already what a grid click stamps — the grid's own page, whatever row was clicked. Preview stamped the row instead, and held the stamp sticky per query identity to keep the window from following the cursor. Both halves were wrong: read live, a row-stamped page rolls the window forward at every page boundary and strands everything behind it; held sticky, the anchor ignores a grid click on the newest image at the top of the board and keeps querying rows 1800+ for a selection at row 0.
Stamping the anchor removes the need for stickiness. Preview's own steps never change the stamp, so it can be read live, and the only things that change it are selections made elsewhere — each of which names the window holding the new selection. Nothing is ever released, because the stamp is the selection's window and changes with the selection.
That invariant holds only if every writer of the stamp names the window the item was selected in. Three did not — they selected without a page, which the reducer fills from the grid's page. Once the grid's anchor had been released (a result landing on the board does that; so does a reload) while Preview's had not, a deletion successor chosen from Preview's own list, "open in preview" from Preview's own menu, and the retained multi-selection after a partial delete were all filed at page 0, outside the window they came from. The action context Preview hands to image actions now carries Preview's own stamp, and those selections use it. Hosts that provide no page keep the exact call they made before.
Two smaller consequences follow: an item the window does not hold — a local recent, or the compare slot's image — is stamped at the top of the listing; and swapping the compare image back out restores the page the item was selected at rather than guessing, so a swap round-trips to a working state. That memo is honoured only in the query it was recorded in — a page names a window of one listing, and restored into another board's query it would anchor that listing 1800 rows down around an image that is not in it.
What earlier versions got wrong, so it isn't repeated
Six rounds of adversarial review rejected five designs and two incomplete ones. Keying the recents guard off the stamped page hid a just-generated batch from Preview the instant it settled. Keying it off the grid's live page suppressed recents for a selection on a different board. Releasing the stamp whenever the grid released its anchor orphaned a deep selection outside its own window. Stamping the top of the listing for any item the window didn't hold collapsed a deep window to row 0 on the first arrow press across a page boundary. And the anchor design itself was incomplete until every page-less writer of the stamp was found.
The common thread: each treated the stamp as something to be corrected from outside, when it is the selection's own window and only a selection may change it.
Scope
Frontend only. The preview hook and view, the action context Preview provides to image actions (one optional method), the multi-selection reducer and command (one optional page), and their tests. No persistence changes.
One recorded behaviour change outside the deep-window case: a paginated boundary crossing now stamps the page landed on rather than the one departed from. On
mainthe continuation looked the item up in data it did not have and fell back to the departed page; the sticky anchor hid it in-session and it only showed on remount.Testing
pnpm run lint(format, oxlint, typecheck, architecture check), 6,686 unit tests, 910 browser tests and the architecture suite pass. Fifteen new cases, each verified to fail against exactly the behaviour it replaces and against nothing else: recents excluded from a deep window (infinite, and with a stamp that outlives a switch to paginated mode); an in-flight image and the compare image stamped at the top; the window holding still across a page boundary and back; Preview moving to the top when a selection is made there from outside it; the action context's page for an item in the window and for a recent; the compare swap round trip, and the memo declining to fire for another item or in another listing; the window anchor handed out for an item on a later page of the window; the successor and the retained multi-selection carrying the host's page; and the reducer stamping an explicit page into the navigation query already on the selection rather than rebuilding it from a grid that has since moved to another board and search.The harness needed corrections that explain why none of this had coverage: the
galleryItemsInfiniteOptionsmock discarded an infinite window's offset; itsgetPreviousPageParamlet an anchored window page upward past its anchor; a re-render built a freshQueryClient, discarding exactly the cached pages a window change has to survive; and the selection command being a mock meant a second arrow press still started from the item the first one left.