Skip to content

fix(preview): stamp the window's anchor, not the row the cursor is on - #175

Open
lstein wants to merge 3 commits into
mainfrom
fix/preview-recents-in-deep-window
Open

fix(preview): stamp the window's anchor, not the row the cursor is on#175
lstein wants to merge 3 commits into
mainfrom
fix/preview-recents-in-deep-window

Conversation

@lstein

@lstein lstein commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

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 (getPreviousPageParam in gallery/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 main 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.

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 galleryItemsInfiniteOptions mock discarded an infinite window's offset; its getPreviousPageParam let an anchored window page upward past its anchor; a re-render built a fresh QueryClient, 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.

@lstein
lstein force-pushed the fix/preview-recents-in-deep-window branch from 6415746 to 019fa7a Compare August 28, 2026 18:40
@lstein lstein changed the title fix(preview): keep settled recents out of a deep-anchored window fix(preview): show recents exactly when the gallery grid does Aug 28, 2026
@lstein lstein changed the title fix(preview): show recents exactly when the gallery grid does fix(preview): release the deep window anchor with the gallery's Aug 28, 2026
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
lstein force-pushed the fix/preview-recents-in-deep-window branch from d053939 to 7aef735 Compare August 28, 2026 22:48
@lstein lstein changed the title fix(preview): release the deep window anchor with the gallery's fix(preview): stamp the window's anchor, not the row the cursor is on Aug 28, 2026
lstein and others added 2 commits August 28, 2026 19:10
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
lstein force-pushed the fix/preview-recents-in-deep-window branch from aa3e4ea to 562b0b0 Compare August 28, 2026 23:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant