diff --git a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx index 9bdbad5dd33..a982e4cb945 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx @@ -110,6 +110,12 @@ To leave semantic search, click the **✕** in the search field, click the spark The sort menu orders media by creation time, **Newest** first (the default) or **Oldest** first. The star button next to it (**Starred only**) shows only starred items. Images and videos are always shown together. +### Browsing large boards + +The media grid continues through the full listing as you scroll, including older items beyond the first few screens. You can also drag the scrollbar to jump to a distant part of the list; the Gallery loads items near that position without first loading every item in between. Scroll back up to browse earlier positions. + +While a part of the list loads, its tile positions stay in place. If one page cannot be loaded, the Gallery marks its first empty tile with an error and **Retry**, while keeping nearby loaded items available. + ## Starring favorites Star an item to keep it at hand. Hover over a thumbnail and click the star in its corner, choose **Star image** (or **Star video**) from its menu, or select items and press **.** (period). Starred thumbnails keep a yellow star. diff --git a/invokeai/app/api/routers/gallery.py b/invokeai/app/api/routers/gallery.py index 102c2879802..06e0731f888 100644 --- a/invokeai/app/api/routers/gallery.py +++ b/invokeai/app/api/routers/gallery.py @@ -7,7 +7,13 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.images import _assert_board_read_access -from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNames, GalleryItemNamesResult +from invokeai.app.services.gallery.gallery_common import ( + GalleryItem, + GalleryItemKind, + GalleryItemLocation, + GalleryItemNames, + GalleryItemNamesResult, +) from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection @@ -72,6 +78,62 @@ def list_gallery_items( ) +@gallery_router.get( + "/items/location", + operation_id="get_gallery_item_location", + response_model=GalleryItemLocation, +) +def get_gallery_item_location( + current_user: CurrentUserOrDefault, + kind: GalleryItemKind = Query(description="Whether the target is an image or video."), + name: str = Query(description="The target image or video name."), + origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."), + categories: Optional[list[ImageCategory]] = Query( + default=None, + description="The categories to include. Shared between images and videos.", + ), + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate items."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find items without a board.", + ), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + starred: Optional[bool] = Query( + default=None, + description="Filter by starred state: true for starred items only, false for unstarred only. Omit to include both.", + ), + search_term: Optional[str] = Query(default=None, description="The term to search for"), + created_from: Optional[date] = Query( + default=None, description="Inclusive start date (YYYY-MM-DD) to filter by created_at." + ), + created_to: Optional[date] = Query( + default=None, description="Inclusive end date (YYYY-MM-DD) to filter by created_at." + ), +) -> GalleryItemLocation: + """Returns exact item's position in an ordinary filtered gallery listing.""" + if board_id is not None and board_id != "none": + _assert_board_read_access(board_id, current_user) + + location = ApiDependencies.invoker.services.gallery.get_item_location( + kind=kind, + name=name, + order_dir=order_dir, + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=current_user.user_id, + is_admin=current_user.is_admin, + created_from=created_from.isoformat() if created_from else None, + created_to=created_to.isoformat() if created_to else None, + starred=starred, + ) + if location is None: + raise HTTPException(status_code=404, detail="Gallery item not found") + return location + + @gallery_router.get( "/item_names", operation_id="list_gallery_item_names", diff --git a/invokeai/app/services/gallery/gallery_base.py b/invokeai/app/services/gallery/gallery_base.py index 51b4f007f78..78047a5f8de 100644 --- a/invokeai/app/services/gallery/gallery_base.py +++ b/invokeai/app/services/gallery/gallery_base.py @@ -4,6 +4,8 @@ from invokeai.app.services.gallery.gallery_common import ( BoardMediaSummary, GalleryItem, + GalleryItemKind, + GalleryItemLocation, GalleryItemNames, GalleryItemNamesResult, ) @@ -37,6 +39,26 @@ def list_items( """Lists a paginated, time-sorted stream of image + video items.""" pass + @abstractmethod + def get_item_location( + self, + kind: GalleryItemKind, + name: str, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_from: Optional[str] = None, + created_to: Optional[str] = None, + starred: Optional[bool] = None, + ) -> Optional[GalleryItemLocation]: + """Finds an item's exact zero-based position in an ordinary item listing.""" + pass + @abstractmethod def list_item_names( self, diff --git a/invokeai/app/services/gallery/gallery_common.py b/invokeai/app/services/gallery/gallery_common.py index 6dd3eab5928..1b147a13c8f 100644 --- a/invokeai/app/services/gallery/gallery_common.py +++ b/invokeai/app/services/gallery/gallery_common.py @@ -26,6 +26,15 @@ class GalleryItemRef(BaseModel): name: str = Field(description="The unique name of the image or video.") +class GalleryItemLocation(BaseModel): + """A gallery item's position in a filtered, ordered listing.""" + + kind: GalleryItemKind = Field(description="Whether the item is an image or video.") + name: str = Field(description="The unique name of the image or video.") + index: int = Field(description="The item's zero-based index in the listing.") + total: int = Field(description="Number of items matching the listing filters.") + + class GalleryItem(BaseModelExcludeNull): """A gallery item — either an image or a video, with shared fields and a discriminator. diff --git a/invokeai/app/services/gallery/gallery_default.py b/invokeai/app/services/gallery/gallery_default.py index 7b2019b2e23..5bd050380c2 100644 --- a/invokeai/app/services/gallery/gallery_default.py +++ b/invokeai/app/services/gallery/gallery_default.py @@ -6,6 +6,7 @@ BoardMediaSummary, GalleryItem, GalleryItemKind, + GalleryItemLocation, GalleryItemNames, GalleryItemNamesResult, GalleryItemRef, @@ -124,6 +125,86 @@ def list_items( total=image_count + video_count, ) + def get_item_location( + self, + kind: GalleryItemKind, + name: str, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, + created_from: Optional[str] = None, + created_to: Optional[str] = None, + starred: Optional[bool] = None, + ) -> Optional[GalleryItemLocation]: + """Finds item's exact rank without building or returning the full name list.""" + image_half, image_params, _ = self._build_half( + kind="image", + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=user_id, + is_admin=is_admin, + names_only=True, + created_from=created_from, + created_to=created_to, + starred=starred, + ) + video_half, video_params, _ = self._build_half( + kind="video", + origin=origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + user_id=user_id, + is_admin=is_admin, + names_only=True, + created_from=created_from, + created_to=created_to, + starred=starred, + ) + + # Keep ranking expressions identical to paged listing order. Window rank avoids + # materializing every matching name in Python while still computing exact index and total. + order_clause = self._build_order_clause(starred_first=False, order_dir=order_dir) + query = f"""--sql + SELECT kind, name, item_index, total + FROM ( + SELECT + kind, + name, + ROW_NUMBER() OVER ({order_clause}) - 1 AS item_index, + COUNT(*) OVER () AS total + FROM ( + {image_half} + UNION ALL + {video_half} + ) + ) + WHERE kind = ? AND name = ? + LIMIT 1; + """ + + with self._db.transaction() as cursor: + cursor.execute(query, image_params + video_params + [kind.value, name]) + row = cursor.fetchone() + + if row is None: + return None + return GalleryItemLocation( + kind=GalleryItemKind(row["kind"]), + name=row["name"], + index=row["item_index"], + total=row["total"], + ) + def _query_name_rows( self, starred_first: bool, diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index 21b5ebab7eb..2178e9612b0 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -9636,6 +9636,224 @@ } } }, + "/api/v1/gallery/items/location": { + "get": { + "tags": ["gallery"], + "summary": "Get Gallery Item Location", + "description": "Returns exact item's position in an ordinary filtered gallery listing.", + "operationId": "get_gallery_item_location", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "kind", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/GalleryItemKind", + "description": "Whether the target is an image or video." + }, + "description": "Whether the target is an image or video." + }, + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "The target image or video name.", + "title": "Name" + }, + "description": "The target image or video name." + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResourceOrigin" + }, + { + "type": "null" + } + ], + "description": "The origin of items to list.", + "title": "Origin" + }, + "description": "The origin of items to list." + }, + { + "name": "categories", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageCategory" + } + }, + { + "type": "null" + } + ], + "description": "The categories to include. Shared between images and videos.", + "title": "Categories" + }, + "description": "The categories to include. Shared between images and videos." + }, + { + "name": "is_intermediate", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to list intermediate items.", + "title": "Is Intermediate" + }, + "description": "Whether to list intermediate items." + }, + { + "name": "board_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The board id to filter by. Use 'none' to find items without a board.", + "title": "Board Id" + }, + "description": "The board id to filter by. Use 'none' to find items without a board." + }, + { + "name": "order_dir", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/SQLiteDirection", + "description": "The order of sort", + "default": "DESC" + }, + "description": "The order of sort" + }, + { + "name": "starred", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Filter by starred state: true for starred items only, false for unstarred only. Omit to include both.", + "title": "Starred" + }, + "description": "Filter by starred state: true for starred items only, false for unstarred only. Omit to include both." + }, + { + "name": "search_term", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The term to search for", + "title": "Search Term" + }, + "description": "The term to search for" + }, + { + "name": "created_from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "description": "Inclusive start date (YYYY-MM-DD) to filter by created_at.", + "title": "Created From" + }, + "description": "Inclusive start date (YYYY-MM-DD) to filter by created_at." + }, + { + "name": "created_to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "description": "Inclusive end date (YYYY-MM-DD) to filter by created_at.", + "title": "Created To" + }, + "description": "Inclusive end date (YYYY-MM-DD) to filter by created_at." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GalleryItemLocation" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/gallery/item_names": { "get": { "tags": ["gallery"], @@ -40686,6 +40904,33 @@ "title": "GalleryItemKind", "description": "Discriminator for polymorphic gallery items." }, + "GalleryItemLocation": { + "properties": { + "kind": { + "$ref": "#/components/schemas/GalleryItemKind", + "description": "Whether the item is an image or video." + }, + "name": { + "type": "string", + "title": "Name", + "description": "The unique name of the image or video." + }, + "index": { + "type": "integer", + "title": "Index", + "description": "The item's zero-based index in the listing." + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Number of items matching the listing filters." + } + }, + "type": "object", + "required": ["kind", "name", "index", "total"], + "title": "GalleryItemLocation", + "description": "A gallery item's position in a filtered, ordered listing." + }, "GalleryItemNames": { "properties": { "item_names": { diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index f085031300a..07069ed8464 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -2287,6 +2287,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/gallery/items/location": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Gallery Item Location + * @description Returns exact item's position in an ordinary filtered gallery listing. + */ + get: operations["get_gallery_item_location"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/gallery/item_names": { parameters: { query?: never; @@ -16317,6 +16337,29 @@ export type components = { * @enum {string} */ GalleryItemKind: "image" | "video"; + /** + * GalleryItemLocation + * @description A gallery item's position in a filtered, ordered listing. + */ + GalleryItemLocation: { + /** @description Whether the item is an image or video. */ + kind: components["schemas"]["GalleryItemKind"]; + /** + * Name + * @description The unique name of the image or video. + */ + name: string; + /** + * Index + * @description The item's zero-based index in the listing. + */ + index: number; + /** + * Total + * @description Number of items matching the listing filters. + */ + total: number; + }; /** * GalleryItemNames * @description Ordered flat list of gallery item names plus counts for optimistic UI. @@ -56358,6 +56401,58 @@ export interface operations { }; }; }; + get_gallery_item_location: { + parameters: { + query: { + /** @description Whether the target is an image or video. */ + kind: components["schemas"]["GalleryItemKind"]; + /** @description The target image or video name. */ + name: string; + /** @description The origin of items to list. */ + origin?: components["schemas"]["ResourceOrigin"] | null; + /** @description The categories to include. Shared between images and videos. */ + categories?: components["schemas"]["ImageCategory"][] | null; + /** @description Whether to list intermediate items. */ + is_intermediate?: boolean | null; + /** @description The board id to filter by. Use 'none' to find items without a board. */ + board_id?: string | null; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Filter by starred state: true for starred items only, false for unstarred only. Omit to include both. */ + starred?: boolean | null; + /** @description The term to search for */ + search_term?: string | null; + /** @description Inclusive start date (YYYY-MM-DD) to filter by created_at. */ + created_from?: string | null; + /** @description Inclusive end date (YYYY-MM-DD) to filter by created_at. */ + created_to?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GalleryItemLocation"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_gallery_item_names: { parameters: { query?: { diff --git a/invokeai/frontend/webv2/ARCHITECTURE.md b/invokeai/frontend/webv2/ARCHITECTURE.md index e0c7e13123e..4fc653de4ca 100644 --- a/invokeai/frontend/webv2/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/ARCHITECTURE.md @@ -42,6 +42,8 @@ The queue pilot is the first migrated feature. Its private `QueueBackendPort` is Queue exposes board-independent active backend sessions, including preparation and result settlement. The Gallery progress section includes every unfinished batch slot, including queued work; Preview follows executing and settling sessions. Both use gallery-destination projections of the active project's queue; eligibility is presentation policy, separate from discovery. Workbench owns transient overview/pinned follow selection for its mounted lifetime, clears pins on project changes or when following stops, and never persists pins or preview frames. Live sessions do not participate in board counts, saved-image selection, or navigation. Saved outputs retain their actual backend board destinations; automatic result selection does not change the browsed board or its paging window. +Gallery ordinary listings are Query-owned sparse pages shared by its grid, picker, Preview, and deep-reveal flows. A page key combines the account lifetime, canonical listing filters, and aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. A cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers the count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by the recent-item limit so an authoritative item does not reappear in the overlay when its page is evicted. The Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies the item at the returned absolute position, and retries that rank/page check at most once if offsets shifted. A persisted Preview selection keeps the listing identity from when the item was selected; changing the active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations, and its asynchronous navigation is fenced by the captured account epoch so a late page cannot stamp selection into a newer account lifetime. + Intermediates owns the manager for temporary media behind `@features/intermediates`: transport mapping, account-fenced query keys, the selection core, the confirmation dialog and the operation panel. It is a Settings section, contributed as metadata through `settingsContribution` so the dialog's catalog never loads the manager or its transport until the section opens. The eligibility policy (safe, referenced, active, recent) is server-owned; the manager requests a server preview, which fixes the instant recency is judged at and describes what a cleanup would delete and keep, and confirms it once. "All matching" selections travel as a `matching` scope carrying the summary's filters and the excluded rows, which the server resolves; the client never enumerates pages it has not loaded. Operations are not persisted by the server: a manager that opens with nothing followed lists the account's operations and follows the newest unfinished one, so a reload picks a running cleanup back up, and a settled operation that left failures behind offers to delete its scope again. The client's one input to the policy is a browser hold lease: `holdLease` (a public entry, started by Workbench for the provider's lifetime) keeps the media open editors still need (live content from every open project plus the undo state each live Canvas engine retains) classified as active. Workbench owns the name set: `projects/projectAssets` scans each immutable project subtree once, and each Canvas engine registers its retained undo state with the mounted Workbench, which re-reads it when the history changes; an engine released by one Workbench is replaced, not reused, by the next, and one another Workbench still uses is refused. The lease is one per tab, holding up to the server's per-kind limit of sorted unique names, which the server replaces atomically; it skips unchanged sets without re-serialising them, forgets a send the server did not acknowledge so the next change, heartbeat or return to the foreground sends again while the previous lease still stands, refreshes every five minutes and when a tab becomes visible after missing a refresh, and releases with `keepalive` on dispose and `pagehide`; the server keeps at most eight leases per account and expires a lease after 15 minutes, which bounds anything a crash leaves behind. Realtime updates attach for the section's mounted lifetime through the Platform socket hub and invalidate the account's summaries when an operation settles, by either the socket or the polled record, or when the socket reconnects. Workbench supplies the entry points (the Data & workspace link, the project actions menu, the Users page callback) and hands the manager a one-shot focus intent (a project to preselect or an account to filter by) rather than route state. Fonts owns its authenticated catalog, managed-library UI, and browser font-registration lifetime behind `@features/fonts`. App composes the session runtime; Canvas receives a font-resolution port and owns text geometry, rendering, and output readiness. Persisted text references and project-file remapping remain in Workbench. Launchpad font previews and Canvas share the same account lifetime; font binaries and browser registration names are not project state. The browser registry admits at most 64 faces and 128 MiB of font input data, including faces currently being parsed. Active references are protected from eviction; if they exhaust the budget, further loads fail until references are released. This bounds retained input, not browser-specific decoded font memory. Canvas reconciles active references only when the document forests or text draft source change. diff --git a/invokeai/frontend/webv2/patches/react-hook-tanstack-virtual.patch b/invokeai/frontend/webv2/patches/react-hook-tanstack-virtual.patch index ca01485a9fc..2302cb8d15a 100644 --- a/invokeai/frontend/webv2/patches/react-hook-tanstack-virtual.patch +++ b/invokeai/frontend/webv2/patches/react-hook-tanstack-virtual.patch @@ -1,26 +1,59 @@ diff --git a/dist/useVirtualizerBase-C3OWQGkj.js b/dist/useVirtualizerBase-C3OWQGkj.js -index 6e8df8ec5304bea75001914bccd115c745a96a44..2cdf2ca421952ab4be1a36cf755fac792833cd20 100644 +index 6e8df8ec5304bea75001914bccd115c745a96a44..216f11eacb0690b8ace42bb4b0209b108974e5bf 100644 --- a/dist/useVirtualizerBase-C3OWQGkj.js +++ b/dist/useVirtualizerBase-C3OWQGkj.js @@ -1,2 +1,2 @@ -import{Virtualizer as e}from"@tanstack/virtual-core";import*as t from"react";import{useCallback as n,useInsertionEffect as r,useMemo as i,useRef as a,useState as o,useSyncExternalStore as s}from"react";import{flushSync as c}from"react-dom";const l=(e,...t)=>e,u=(e,t)=>{if(e.size!==t.size)return!1;for(let[n,r]of e){let e=t.get(n);if(r!==e||!Object.is(r,e))return!1}return!0},d=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},f=(e,t)=>{if(e===t||Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map)return u(e,t);if(e instanceof Set&&t instanceof Set)return d(e,t);let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n){if(!Object.hasOwn(t,r))return!1;let{[r]:n}=e,{[r]:i}=t;if(n!==i||!Object.is(n,i))return!1}return!0},p=typeof document<`u`?t.useLayoutEffect:()=>{},m=e=>{let[t,n]=o(e);return f(t,e)||n(e),t};let h=1,g=!1;const _=new Map,v=({callback:e,args:t})=>e.apply(void 0,t),y=e=>{if(g){x(y,e);return}let t=_.get(e);if(t){g=!0;try{v(t)}finally{S(e),g=!1}}},b=`scheduler`in globalThis?{setImmediate:(e,...t)=>{let n=h++;return _.set(n,{callback:e,args:t}),scheduler.postTask(()=>y(n),{priority:`user-blocking`}),n},clearImmediate:e=>{_.delete(e)}}:(()=>{let e=new MessageChannel;e.port1.onmessage=function(e){let t=e.data;y(t)};let t=e.port2;return{setImmediate:(e,...n)=>{let r=h++;return _.set(r,{callback:e,args:n}),t.postMessage(r),r},clearImmediate:e=>{_.delete(e)}}})(),x=b.setImmediate,S=b.clearImmediate;let C=1;const w=new Map,T=({callback:e,args:t})=>e.apply(void 0,t),E=e=>{let t=w.get(e);if(t)try{T(t)}finally{O(e)}},D=(e,...t)=>{let n=C++;return w.set(n,{callback:e,args:t}),requestAnimationFrame(()=>x(()=>E(n))),n},O=e=>{w.delete(e)};function k(e){let[t,r]=o(e);return[t,n(e=>(r(e),()=>{r(null)}),[])]}const A=({getVirtualItems:e,getVirtualIndexes:t,getTotalSize:n,...r})=>({...r,virtualItems:e(),virtualIndexes:t(),totalSize:n()}),j=({options:e,virtualIndexes:t,virtualItems:n,...r},{options:i,virtualIndexes:a,virtualItems:o,...s})=>f(e,i)&&f(t,a)&&f(n,o)&&f(r,s),M=({useFlushSync:t=!0,directDomUpdates:u=!1,directDomUpdatesMode:d=`transform`,...h},g=l,_=f)=>{"use no memo";let v=m(h),{horizontal:y=!1,scrollMargin:b=0}=v,x=y?`width`:`height`,S=d===`transform`,[C,w]=o(null),[T,E]=k(null),[M]=o(()=>new WeakMap),[N]=o(()=>new WeakMap),[P]=o(()=>new e(v)),F=i(()=>Object.assign(P,{containerRef:E}),[P,E]);p(()=>{if(!u||!T||C==null)return;let e=``,t=D(()=>{e=T.style.getPropertyValue(x),t=requestAnimationFrame(()=>{T.style.setProperty(x,`${C}px`)})});return()=>{w(null),cancelAnimationFrame(t),O(t),requestAnimationFrame(()=>{T?.style.setProperty(x,e)})}},[x,T,u,C]),p(()=>{if(!(!u||!T))return()=>{requestAnimationFrame(()=>{let e=F.getVirtualItems();for(let t of e){let e=F.elementsCache.get(t.key);if(!e)continue;M.delete(e);let n=N.get(e);n!=null&&(N.delete(e),S?e.style.setProperty(`transform`,n):e.style.setProperty(x,n))}})}},[x,T,u,F,M,N,S]);let I=a({hasValue:!1,value:null}),[L]=o(()=>new Set),R=n(e=>(L.add(e),()=>{L.delete(e)}),[L]),z=i(()=>{let e=!1,t,n;return()=>{let r=A(F);if(!e){e=!0,t=r;let i=g(r,F);if(I.current.hasValue){let e=I.current.value;if(_(e,i))return n=e,e}return n=i,i}let i=t,a=n;if(j(i,r))return a;let o=g(r,F);return _!==void 0&&_(a,o)?(t=r,a):(t=r,n=o,o)}},[g,_,F]),B=n(e=>{if(!u||!T)return;w(e.getTotalSize());let t=e.getVirtualItems();for(let n of t){let t=n.start-b,r=e.elementsCache.get(n.key);r&&M.get(r)!==t&&(N.has(r)||(S?N.set(r,r.style.getPropertyValue(`transform`)):N.set(r,r.style.getPropertyValue(x))),M.set(r,t),S?r.style.setProperty(`transform`,y?`translate3d(${t}px, 0, 0)`:`translate3d(0, ${t}px, 0)`):r.style.setProperty(x,`${t}px`))}},[x,T,u,y,M,N,b,S]),V=n((e,n)=>{if(B(e),t&&n)c(()=>{for(let e of L)e()});else for(let e of L)e();v.onChange?.call(void 0,e,n)},[B,L,v.onChange,t]),H=i(()=>({...v,onChange:V}),[V,v]);p(()=>{F.setOptions(H)},[F,H]),p(()=>F._didMount(),[F]),p(()=>F._willUpdate()),p(()=>{B(F)});let U=s(R,z);return r(()=>{I.current.hasValue=!0,I.current.value=U},[U]),U};export{M as t}; -+import{Virtualizer as e}from"@tanstack/virtual-core";import*as t from"react";import{useCallback as n,useInsertionEffect as r,useMemo as i,useRef as a,useState as o,useSyncExternalStore as s}from"react";import{flushSync as c}from"react-dom";const l=(e,...t)=>e,u=(e,t)=>{if(e.size!==t.size)return!1;for(let[n,r]of e){let e=t.get(n);if(r!==e||!Object.is(r,e))return!1}return!0},d=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},f=(e,t)=>{if(e===t||Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map)return u(e,t);if(e instanceof Set&&t instanceof Set)return d(e,t);let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n){if(!Object.hasOwn(t,r))return!1;let{[r]:n}=e,{[r]:i}=t;if(n!==i||!Object.is(n,i))return!1}return!0},p=typeof document<`u`?t.useLayoutEffect:()=>{},m=e=>{let[t,n]=o(e);return f(t,e)||n(e),t};let h=1,g=!1;const _=new Map,v=({callback:e,args:t})=>e.apply(void 0,t),y=e=>{if(g){x(y,e);return}let t=_.get(e);if(t){g=!0;try{v(t)}finally{S(e),g=!1}}},b=`scheduler`in globalThis?{setImmediate:(e,...t)=>{let n=h++;return _.set(n,{callback:e,args:t}),scheduler.postTask(()=>y(n),{priority:`user-blocking`}),n},clearImmediate:e=>{_.delete(e)}}:(()=>{let e=new MessageChannel;e.port1.onmessage=function(e){let t=e.data;y(t)};let t=e.port2;return{setImmediate:(e,...n)=>{let r=h++;return _.set(r,{callback:e,args:n}),t.postMessage(r),r},clearImmediate:e=>{_.delete(e)}}})(),x=b.setImmediate,S=b.clearImmediate;let C=1;const w=new Map,T=({callback:e,args:t})=>e.apply(void 0,t),E=e=>{let t=w.get(e);if(t)try{T(t)}finally{O(e)}},D=(e,...t)=>{let n=C++;return w.set(n,{callback:e,args:t}),requestAnimationFrame(()=>x(()=>E(n))),n},O=e=>{w.delete(e)};function k(e){let[t,r]=o(e);return[t,n(e=>(r(e),()=>{r(null)}),[])]}const A=({getVirtualItems:e,getVirtualIndexes:t,getTotalSize:n,...r})=>({...r,virtualItems:e(),virtualIndexes:t(),totalSize:n()}),j=({options:e,virtualIndexes:t,virtualItems:n,...r},{options:i,virtualIndexes:a,virtualItems:o,...s})=>f(e,i)&&f(t,a)&&f(n,o)&&f(r,s),M=({useFlushSync:t=!0,directDomUpdates:u=!1,directDomUpdatesMode:d=`transform`,...h},g=l,_=f)=>{"use no memo";let v=m(h),{horizontal:y=!1,scrollMargin:b=0}=v,x=y?`width`:`height`,S=d===`transform`,[C,w]=o(null),[T,E]=k(null),[M]=o(()=>new WeakMap),[N]=o(()=>new WeakMap),[P]=o(()=>new e(v)),F=i(()=>Object.assign(P,{containerRef:E}),[P,E]);p(()=>{if(!u||!T||C==null)return;let e=``,t=D(()=>{e=T.style.getPropertyValue(x),t=requestAnimationFrame(()=>{T.style.setProperty(x,`${C}px`)})});return()=>{w(null),cancelAnimationFrame(t),O(t),requestAnimationFrame(()=>{T?.style.setProperty(x,e)})}},[x,T,u,C]),p(()=>{if(!(!u||!T))return()=>{requestAnimationFrame(()=>{let e=F.getVirtualItems();for(let t of e){let e=F.elementsCache.get(t.key);if(!e)continue;M.delete(e);let n=N.get(e);n!=null&&(N.delete(e),S?e.style.setProperty(`transform`,n):e.style.setProperty(x,n))}})}},[x,T,u,F,M,N,S]);let I=a({hasValue:!1,value:null}),[L]=o(()=>new Set),R=n(e=>(L.add(e),()=>{L.delete(e)}),[L]),z=i(()=>{let e=!1,t,n;return()=>{let r=A(F);if(!e){e=!0,t=r;let i=g(r,F);if(I.current.hasValue){let e=I.current.value;if(_(e,i))return n=e,e}return n=i,i}let i=t,a=n;if(j(i,r))return a;let o=g(r,F);return _!==void 0&&_(a,o)?(t=r,a):(t=r,n=o,o)}},[g,_,F]),B=n(e=>{if(!u||!T)return;w(e.getTotalSize());let t=e.getVirtualItems();for(let n of t){let t=n.start-b,r=e.elementsCache.get(n.key);r&&M.get(r)!==t&&(N.has(r)||(S?N.set(r,r.style.getPropertyValue(`transform`)):N.set(r,r.style.getPropertyValue(x))),M.set(r,t),S?r.style.setProperty(`transform`,y?`translate3d(${t}px, 0, 0)`:`translate3d(0, ${t}px, 0)`):r.style.setProperty(x,`${t}px`))}},[x,T,u,y,M,N,b,S]),V=n((e,n)=>{if(B(e),t&&n)c(()=>{for(let e of L)e()});else for(let e of L)e();v.onChange?.call(void 0,e,n)},[B,L,v.onChange,t]),H=i(()=>({...v,onChange:V}),[V,v]);p(()=>{F.setOptions(H),F.maybeNotify()},[F,H]),p(()=>F._didMount(),[F]),p(()=>{const e=F._willUpdate(),t=F.scrollElement;if(t&&`scrollTop`in t&&!F.isScrolling){const e=F.options.horizontal?t.scrollLeft*(F.options.isRtl?-1:1):t.scrollTop;F.scrollOffset!==null&&Math.abs(F.scrollOffset-e)>1&&(F.scrollOffset=e,F.scrollAdjustments=0,F.maybeNotify())}return e}),p(()=>{B(F)});let U=s(R,z);return r(()=>{I.current.hasValue=!0,I.current.value=U},[U]),U};export{M as t}; ++import{Virtualizer as e}from"@tanstack/virtual-core";import*as t from"react";import{useCallback as n,useInsertionEffect as r,useMemo as i,useRef as a,useState as o,useSyncExternalStore as s}from"react";import{flushSync as c}from"react-dom";const l=(e,...t)=>e,u=(e,t)=>{if(e.size!==t.size)return!1;for(let[n,r]of e){let e=t.get(n);if(r!==e||!Object.is(r,e))return!1}return!0},d=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},f=(e,t)=>{if(e===t||Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map)return u(e,t);if(e instanceof Set&&t instanceof Set)return d(e,t);let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n){if(!Object.hasOwn(t,r))return!1;let{[r]:n}=e,{[r]:i}=t;if(n!==i||!Object.is(n,i))return!1}return!0},p=typeof document<`u`?t.useLayoutEffect:()=>{},m=e=>{let[t,n]=o(e);return f(t,e)||n(e),t};let h=1,g=!1;const _=new Map,v=({callback:e,args:t})=>e.apply(void 0,t),y=e=>{if(g){x(y,e);return}let t=_.get(e);if(t){g=!0;try{v(t)}finally{S(e),g=!1}}},b=`scheduler`in globalThis?{setImmediate:(e,...t)=>{let n=h++;return _.set(n,{callback:e,args:t}),scheduler.postTask(()=>y(n),{priority:`user-blocking`}),n},clearImmediate:e=>{_.delete(e)}}:(()=>{let e=new MessageChannel;e.port1.onmessage=function(e){let t=e.data;y(t)};let t=e.port2;return{setImmediate:(e,...n)=>{let r=h++;return _.set(r,{callback:e,args:n}),t.postMessage(r),r},clearImmediate:e=>{_.delete(e)}}})(),x=b.setImmediate,S=b.clearImmediate;let C=1;const w=new Map,T=({callback:e,args:t})=>e.apply(void 0,t),E=e=>{let t=w.get(e);if(t)try{T(t)}finally{O(e)}},D=(e,...t)=>{let n=C++;return w.set(n,{callback:e,args:t}),requestAnimationFrame(()=>x(()=>E(n))),n},O=e=>{w.delete(e)};function k(e){let[t,r]=o(e);return[t,n(e=>(r(e),()=>{r(null)}),[])]}const A=(e=>{const t=e.getVirtualItems(),n=e.getVirtualIndexes(),r=e.getTotalSize(),{getVirtualItems:i,getVirtualIndexes:a,getTotalSize:o,...s}=e;return{...s,virtualItems:t,virtualIndexes:n,totalSize:r}}),j=({options:e,virtualIndexes:t,virtualItems:n,...r},{options:i,virtualIndexes:a,virtualItems:o,...s})=>f(e,i)&&f(t,a)&&f(n,o)&&f(r,s),M=({useFlushSync:t=!0,directDomUpdates:u=!1,directDomUpdatesMode:d=`transform`,...h},g=l,_=f)=>{"use no memo";let v=m(h),{horizontal:y=!1,scrollMargin:b=0}=v,x=y?`width`:`height`,S=d===`transform`,[C,w]=o(null),[T,E]=k(null),[M]=o(()=>new WeakMap),[N]=o(()=>new WeakMap),[P]=o(()=>new e(v)),F=i(()=>Object.assign(P,{containerRef:E}),[P,E]);p(()=>{if(!u||!T||C==null)return;let e=``,t=D(()=>{e=T.style.getPropertyValue(x),t=requestAnimationFrame(()=>{T.style.setProperty(x,`${C}px`)})});return()=>{w(null),cancelAnimationFrame(t),O(t),requestAnimationFrame(()=>{T?.style.setProperty(x,e)})}},[x,T,u,C]),p(()=>{if(!(!u||!T))return()=>{requestAnimationFrame(()=>{let e=F.getVirtualItems();for(let t of e){let e=F.elementsCache.get(t.key);if(!e)continue;M.delete(e);let n=N.get(e);n!=null&&(N.delete(e),S?e.style.setProperty(`transform`,n):e.style.setProperty(x,n))}})}},[x,T,u,F,M,N,S]);let I=a({hasValue:!1,value:null}),[L]=o(()=>new Set),R=n(e=>(L.add(e),()=>{L.delete(e)}),[L]),z=i(()=>{let e=!1,t,n;return()=>{let r=A(F);if(!e){e=!0,t=r;let i=g(r,F);if(I.current.hasValue){let e=I.current.value;if(_(e,i))return n=e,e}return n=i,i}let i=t,a=n;if(j(i,r))return a;let o=g(r,F);return _!==void 0&&_(a,o)?(t=r,a):(t=r,n=o,o)}},[g,_,F]),B=n(e=>{if(!u||!T)return;w(e.getTotalSize());let t=e.getVirtualItems();for(let n of t){let t=n.start-b,r=e.elementsCache.get(n.key);r&&M.get(r)!==t&&(N.has(r)||(S?N.set(r,r.style.getPropertyValue(`transform`)):N.set(r,r.style.getPropertyValue(x))),M.set(r,t),S?r.style.setProperty(`transform`,y?`translate3d(${t}px, 0, 0)`:`translate3d(0, ${t}px, 0)`):r.style.setProperty(x,`${t}px`))}},[x,T,u,y,M,N,b,S]),V=n((e,n)=>{if(B(e),t&&n)c(()=>{for(let e of L)e()});else for(let e of L)e();v.onChange?.call(void 0,e,n)},[B,L,v.onChange,t]),H=i(()=>({...v,onChange:V}),[V,v]);p(()=>{F.setOptions(H),F.maybeNotify()},[F,H]),p(()=>F._didMount(),[F]),p(()=>{const e=F._willUpdate(),t=F.scrollElement;if(t&&`scrollTop`in t&&!F.isScrolling){const e=F.options.horizontal?t.scrollLeft*(F.options.isRtl?-1:1):t.scrollTop;F.scrollOffset!==null&&Math.abs(F.scrollOffset-e)>1&&(F.scrollOffset=e,F.scrollAdjustments=0,F.maybeNotify())}return e}),p(()=>{B(F)});let U=s(R,z);return r(()=>{I.current.hasValue=!0,I.current.value=U},[U]),U};export{M as t}; //# sourceMappingURL=useVirtualizerBase-C3OWQGkj.js.map \ No newline at end of file diff --git a/dist/useVirtualizerBase-K4A1fZan.cjs b/dist/useVirtualizerBase-K4A1fZan.cjs -index 69494e5c083f991a4b7417cd2b6a216be3bf00e6..df02f4d2032b807b6b2c2fc3881051254a55fb71 100644 +index 69494e5c083f991a4b7417cd2b6a216be3bf00e6..97f29a50bf96dca9c0975aef25ddda989f63efde 100644 --- a/dist/useVirtualizerBase-K4A1fZan.cjs +++ b/dist/useVirtualizerBase-K4A1fZan.cjs -@@ -1,1 +1,1 @@ +@@ -1 +1 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require("@tanstack/virtual-core"),l=require("react");l=s(l,1);let u=require("react-dom");const d=(e,...t)=>e,f=(e,t)=>{if(e.size!==t.size)return!1;for(let[n,r]of e){let e=t.get(n);if(r!==e||!Object.is(r,e))return!1}return!0},p=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},m=(e,t)=>{if(e===t||Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map)return f(e,t);if(e instanceof Set&&t instanceof Set)return p(e,t);let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n){if(!Object.hasOwn(t,r))return!1;let{[r]:n}=e,{[r]:i}=t;if(n!==i||!Object.is(n,i))return!1}return!0},h=typeof document<`u`?l.useLayoutEffect:()=>{},g=e=>{let[t,n]=(0,l.useState)(e);return m(t,e)||n(e),t};let _=1,v=!1;const y=new Map,b=({callback:e,args:t})=>e.apply(void 0,t),x=e=>{if(v){C(x,e);return}let t=y.get(e);if(t){v=!0;try{b(t)}finally{w(e),v=!1}}},S=`scheduler`in globalThis?{setImmediate:(e,...t)=>{let n=_++;return y.set(n,{callback:e,args:t}),scheduler.postTask(()=>x(n),{priority:`user-blocking`}),n},clearImmediate:e=>{y.delete(e)}}:(()=>{let e=new MessageChannel;e.port1.onmessage=function(e){let t=e.data;x(t)};let t=e.port2;return{setImmediate:(e,...n)=>{let r=_++;return y.set(r,{callback:e,args:n}),t.postMessage(r),r},clearImmediate:e=>{y.delete(e)}}})(),C=S.setImmediate,w=S.clearImmediate;let T=1;const E=new Map,D=({callback:e,args:t})=>e.apply(void 0,t),O=e=>{let t=E.get(e);if(t)try{D(t)}finally{A(e)}},k=(e,...t)=>{let n=T++;return E.set(n,{callback:e,args:t}),requestAnimationFrame(()=>C(()=>O(n))),n},A=e=>{E.delete(e)};function j(e){let[t,n]=(0,l.useState)(e);return[t,(0,l.useCallback)(e=>(n(e),()=>{n(null)}),[])]}const M=({getVirtualItems:e,getVirtualIndexes:t,getTotalSize:n,...r})=>({...r,virtualItems:e(),virtualIndexes:t(),totalSize:n()}),N=({options:e,virtualIndexes:t,virtualItems:n,...r},{options:i,virtualIndexes:a,virtualItems:o,...s})=>m(e,i)&&m(t,a)&&m(n,o)&&m(r,s),P=({useFlushSync:e=!0,directDomUpdates:t=!1,directDomUpdatesMode:n=`transform`,...r},i=d,a=m)=>{"use no memo";let o=g(r),{horizontal:s=!1,scrollMargin:f=0}=o,p=s?`width`:`height`,_=n===`transform`,[v,y]=(0,l.useState)(null),[b,x]=j(null),[S]=(0,l.useState)(()=>new WeakMap),[C]=(0,l.useState)(()=>new WeakMap),[w]=(0,l.useState)(()=>new c.Virtualizer(o)),T=(0,l.useMemo)(()=>Object.assign(w,{containerRef:x}),[w,x]);h(()=>{if(!t||!b||v==null)return;let e=``,n=k(()=>{e=b.style.getPropertyValue(p),n=requestAnimationFrame(()=>{b.style.setProperty(p,`${v}px`)})});return()=>{y(null),cancelAnimationFrame(n),A(n),requestAnimationFrame(()=>{b?.style.setProperty(p,e)})}},[p,b,t,v]),h(()=>{if(!(!t||!b))return()=>{requestAnimationFrame(()=>{let e=T.getVirtualItems();for(let t of e){let e=T.elementsCache.get(t.key);if(!e)continue;S.delete(e);let n=C.get(e);n!=null&&(C.delete(e),_?e.style.setProperty(`transform`,n):e.style.setProperty(p,n))}})}},[p,b,t,T,S,C,_]);let E=(0,l.useRef)({hasValue:!1,value:null}),[D]=(0,l.useState)(()=>new Set),O=(0,l.useCallback)(e=>(D.add(e),()=>{D.delete(e)}),[D]),P=(0,l.useMemo)(()=>{let e=!1,t,n;return()=>{let r=M(T);if(!e){e=!0,t=r;let o=i(r,T);if(E.current.hasValue){let e=E.current.value;if(a(e,o))return n=e,e}return n=o,o}let o=t,s=n;if(N(o,r))return s;let c=i(r,T);return a!==void 0&&a(s,c)?(t=r,s):(t=r,n=c,c)}},[i,a,T]),F=(0,l.useCallback)(e=>{if(!t||!b)return;y(e.getTotalSize());let n=e.getVirtualItems();for(let t of n){let n=t.start-f,r=e.elementsCache.get(t.key);r&&S.get(r)!==n&&(C.has(r)||(_?C.set(r,r.style.getPropertyValue(`transform`)):C.set(r,r.style.getPropertyValue(p))),S.set(r,n),_?r.style.setProperty(`transform`,s?`translate3d(${n}px, 0, 0)`:`translate3d(0, ${n}px, 0)`):r.style.setProperty(p,`${n}px`))}},[p,b,t,s,S,C,f,_]),I=(0,l.useCallback)((t,n)=>{if(F(t),e&&n)(0,u.flushSync)(()=>{for(let e of D)e()});else for(let e of D)e();o.onChange?.call(void 0,t,n)},[F,D,o.onChange,e]),L=(0,l.useMemo)(()=>({...o,onChange:I}),[I,o]);h(()=>{T.setOptions(L)},[T,L]),h(()=>T._didMount(),[T]),h(()=>T._willUpdate()),h(()=>{F(T)});let R=(0,l.useSyncExternalStore)(O,P);return(0,l.useInsertionEffect)(()=>{E.current.hasValue=!0,E.current.value=R},[R]),R};Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return P}}); \ No newline at end of file -+var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require("@tanstack/virtual-core"),l=require("react");l=s(l,1);let u=require("react-dom");const d=(e,...t)=>e,f=(e,t)=>{if(e.size!==t.size)return!1;for(let[n,r]of e){let e=t.get(n);if(r!==e||!Object.is(r,e))return!1}return!0},p=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},m=(e,t)=>{if(e===t||Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map)return f(e,t);if(e instanceof Set&&t instanceof Set)return p(e,t);let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n){if(!Object.hasOwn(t,r))return!1;let{[r]:n}=e,{[r]:i}=t;if(n!==i||!Object.is(n,i))return!1}return!0},h=typeof document<`u`?l.useLayoutEffect:()=>{},g=e=>{let[t,n]=(0,l.useState)(e);return m(t,e)||n(e),t};let _=1,v=!1;const y=new Map,b=({callback:e,args:t})=>e.apply(void 0,t),x=e=>{if(v){C(x,e);return}let t=y.get(e);if(t){v=!0;try{b(t)}finally{w(e),v=!1}}},S=`scheduler`in globalThis?{setImmediate:(e,...t)=>{let n=_++;return y.set(n,{callback:e,args:t}),scheduler.postTask(()=>x(n),{priority:`user-blocking`}),n},clearImmediate:e=>{y.delete(e)}}:(()=>{let e=new MessageChannel;e.port1.onmessage=function(e){let t=e.data;x(t)};let t=e.port2;return{setImmediate:(e,...n)=>{let r=_++;return y.set(r,{callback:e,args:n}),t.postMessage(r),r},clearImmediate:e=>{y.delete(e)}}})(),C=S.setImmediate,w=S.clearImmediate;let T=1;const E=new Map,D=({callback:e,args:t})=>e.apply(void 0,t),O=e=>{let t=E.get(e);if(t)try{D(t)}finally{A(e)}},k=(e,...t)=>{let n=T++;return E.set(n,{callback:e,args:t}),requestAnimationFrame(()=>C(()=>O(n))),n},A=e=>{E.delete(e)};function j(e){let[t,n]=(0,l.useState)(e);return[t,(0,l.useCallback)(e=>(n(e),()=>{n(null)}),[])]}const M=({getVirtualItems:e,getVirtualIndexes:t,getTotalSize:n,...r})=>({...r,virtualItems:e(),virtualIndexes:t(),totalSize:n()}),N=({options:e,virtualIndexes:t,virtualItems:n,...r},{options:i,virtualIndexes:a,virtualItems:o,...s})=>m(e,i)&&m(t,a)&&m(n,o)&&m(r,s),P=({useFlushSync:e=!0,directDomUpdates:t=!1,directDomUpdatesMode:n=`transform`,...r},i=d,a=m)=>{"use no memo";let o=g(r),{horizontal:s=!1,scrollMargin:f=0}=o,p=s?`width`:`height`,_=n===`transform`,[v,y]=(0,l.useState)(null),[b,x]=j(null),[S]=(0,l.useState)(()=>new WeakMap),[C]=(0,l.useState)(()=>new WeakMap),[w]=(0,l.useState)(()=>new c.Virtualizer(o)),T=(0,l.useMemo)(()=>Object.assign(w,{containerRef:x}),[w,x]);h(()=>{if(!t||!b||v==null)return;let e=``,n=k(()=>{e=b.style.getPropertyValue(p),n=requestAnimationFrame(()=>{b.style.setProperty(p,`${v}px`)})});return()=>{y(null),cancelAnimationFrame(n),A(n),requestAnimationFrame(()=>{b?.style.setProperty(p,e)})}},[p,b,t,v]),h(()=>{if(!(!t||!b))return()=>{requestAnimationFrame(()=>{let e=T.getVirtualItems();for(let t of e){let e=T.elementsCache.get(t.key);if(!e)continue;S.delete(e);let n=C.get(e);n!=null&&(C.delete(e),_?e.style.setProperty(`transform`,n):e.style.setProperty(p,n))}})}},[p,b,t,T,S,C,_]);let E=(0,l.useRef)({hasValue:!1,value:null}),[D]=(0,l.useState)(()=>new Set),O=(0,l.useCallback)(e=>(D.add(e),()=>{D.delete(e)}),[D]),P=(0,l.useMemo)(()=>{let e=!1,t,n;return()=>{let r=M(T);if(!e){e=!0,t=r;let o=i(r,T);if(E.current.hasValue){let e=E.current.value;if(a(e,o))return n=e,e}return n=o,o}let o=t,s=n;if(N(o,r))return s;let c=i(r,T);return a!==void 0&&a(s,c)?(t=r,s):(t=r,n=c,c)}},[i,a,T]),F=(0,l.useCallback)(e=>{if(!t||!b)return;y(e.getTotalSize());let n=e.getVirtualItems();for(let t of n){let n=t.start-f,r=e.elementsCache.get(t.key);r&&S.get(r)!==n&&(C.has(r)||(_?C.set(r,r.style.getPropertyValue(`transform`)):C.set(r,r.style.getPropertyValue(p))),S.set(r,n),_?r.style.setProperty(`transform`,s?`translate3d(${n}px, 0, 0)`:`translate3d(0, ${n}px, 0)`):r.style.setProperty(p,`${n}px`))}},[p,b,t,s,S,C,f,_]),I=(0,l.useCallback)((t,n)=>{if(F(t),e&&n)(0,u.flushSync)(()=>{for(let e of D)e()});else for(let e of D)e();o.onChange?.call(void 0,t,n)},[F,D,o.onChange,e]),L=(0,l.useMemo)(()=>({...o,onChange:I}),[I,o]);h(()=>{T.setOptions(L),T.maybeNotify()},[T,L]),h(()=>T._didMount(),[T]),h(()=>{const e=T._willUpdate(),t=T.scrollElement;if(t&&`scrollTop`in t&&!T.isScrolling){const e=T.options.horizontal?t.scrollLeft*(T.options.isRtl?-1:1):t.scrollTop;T.scrollOffset!==null&&Math.abs(T.scrollOffset-e)>1&&(T.scrollOffset=e,T.scrollAdjustments=0,T.maybeNotify())}return e}),h(()=>{F(T)});let R=(0,l.useSyncExternalStore)(O,P);return(0,l.useInsertionEffect)(()=>{E.current.hasValue=!0,E.current.value=R},[R]),R};Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return P}}); ++var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require("@tanstack/virtual-core"),l=require("react");l=s(l,1);let u=require("react-dom");const d=(e,...t)=>e,f=(e,t)=>{if(e.size!==t.size)return!1;for(let[n,r]of e){let e=t.get(n);if(r!==e||!Object.is(r,e))return!1}return!0},p=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},m=(e,t)=>{if(e===t||Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map)return f(e,t);if(e instanceof Set&&t instanceof Set)return p(e,t);let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n){if(!Object.hasOwn(t,r))return!1;let{[r]:n}=e,{[r]:i}=t;if(n!==i||!Object.is(n,i))return!1}return!0},h=typeof document<`u`?l.useLayoutEffect:()=>{},g=e=>{let[t,n]=(0,l.useState)(e);return m(t,e)||n(e),t};let _=1,v=!1;const y=new Map,b=({callback:e,args:t})=>e.apply(void 0,t),x=e=>{if(v){C(x,e);return}let t=y.get(e);if(t){v=!0;try{b(t)}finally{w(e),v=!1}}},S=`scheduler`in globalThis?{setImmediate:(e,...t)=>{let n=_++;return y.set(n,{callback:e,args:t}),scheduler.postTask(()=>x(n),{priority:`user-blocking`}),n},clearImmediate:e=>{y.delete(e)}}:(()=>{let e=new MessageChannel;e.port1.onmessage=function(e){let t=e.data;x(t)};let t=e.port2;return{setImmediate:(e,...n)=>{let r=_++;return y.set(r,{callback:e,args:n}),t.postMessage(r),r},clearImmediate:e=>{y.delete(e)}}})(),C=S.setImmediate,w=S.clearImmediate;let T=1;const E=new Map,D=({callback:e,args:t})=>e.apply(void 0,t),O=e=>{let t=E.get(e);if(t)try{D(t)}finally{A(e)}},k=(e,...t)=>{let n=T++;return E.set(n,{callback:e,args:t}),requestAnimationFrame(()=>C(()=>O(n))),n},A=e=>{E.delete(e)};function j(e){let[t,n]=(0,l.useState)(e);return[t,(0,l.useCallback)(e=>(n(e),()=>{n(null)}),[])]}const M=(e=>{const t=e.getVirtualItems(),n=e.getVirtualIndexes(),r=e.getTotalSize(),{getVirtualItems:i,getVirtualIndexes:a,getTotalSize:o,...s}=e;return{...s,virtualItems:t,virtualIndexes:n,totalSize:r}}),N=({options:e,virtualIndexes:t,virtualItems:n,...r},{options:i,virtualIndexes:a,virtualItems:o,...s})=>m(e,i)&&m(t,a)&&m(n,o)&&m(r,s),P=({useFlushSync:e=!0,directDomUpdates:t=!1,directDomUpdatesMode:n=`transform`,...r},i=d,a=m)=>{"use no memo";let o=g(r),{horizontal:s=!1,scrollMargin:f=0}=o,p=s?`width`:`height`,_=n===`transform`,[v,y]=(0,l.useState)(null),[b,x]=j(null),[S]=(0,l.useState)(()=>new WeakMap),[C]=(0,l.useState)(()=>new WeakMap),[w]=(0,l.useState)(()=>new c.Virtualizer(o)),T=(0,l.useMemo)(()=>Object.assign(w,{containerRef:x}),[w,x]);h(()=>{if(!t||!b||v==null)return;let e=``,n=k(()=>{e=b.style.getPropertyValue(p),n=requestAnimationFrame(()=>{b.style.setProperty(p,`${v}px`)})});return()=>{y(null),cancelAnimationFrame(n),A(n),requestAnimationFrame(()=>{b?.style.setProperty(p,e)})}},[p,b,t,v]),h(()=>{if(!(!t||!b))return()=>{requestAnimationFrame(()=>{let e=T.getVirtualItems();for(let t of e){let e=T.elementsCache.get(t.key);if(!e)continue;S.delete(e);let n=C.get(e);n!=null&&(C.delete(e),_?e.style.setProperty(`transform`,n):e.style.setProperty(p,n))}})}},[p,b,t,T,S,C,_]);let E=(0,l.useRef)({hasValue:!1,value:null}),[D]=(0,l.useState)(()=>new Set),O=(0,l.useCallback)(e=>(D.add(e),()=>{D.delete(e)}),[D]),P=(0,l.useMemo)(()=>{let e=!1,t,n;return()=>{let r=M(T);if(!e){e=!0,t=r;let o=i(r,T);if(E.current.hasValue){let e=E.current.value;if(a(e,o))return n=e,e}return n=o,o}let o=t,s=n;if(N(o,r))return s;let c=i(r,T);return a!==void 0&&a(s,c)?(t=r,s):(t=r,n=c,c)}},[i,a,T]),F=(0,l.useCallback)(e=>{if(!t||!b)return;y(e.getTotalSize());let n=e.getVirtualItems();for(let t of n){let n=t.start-f,r=e.elementsCache.get(t.key);r&&S.get(r)!==n&&(C.has(r)||(_?C.set(r,r.style.getPropertyValue(`transform`)):C.set(r,r.style.getPropertyValue(p))),S.set(r,n),_?r.style.setProperty(`transform`,s?`translate3d(${n}px, 0, 0)`:`translate3d(0, ${n}px, 0)`):r.style.setProperty(p,`${n}px`))}},[p,b,t,s,S,C,f,_]),I=(0,l.useCallback)((t,n)=>{if(F(t),e&&n)(0,u.flushSync)(()=>{for(let e of D)e()});else for(let e of D)e();o.onChange?.call(void 0,t,n)},[F,D,o.onChange,e]),L=(0,l.useMemo)(()=>({...o,onChange:I}),[I,o]);h(()=>{T.setOptions(L),T.maybeNotify()},[T,L]),h(()=>T._didMount(),[T]),h(()=>{const e=T._willUpdate(),t=T.scrollElement;if(t&&`scrollTop`in t&&!T.isScrolling){const e=T.options.horizontal?t.scrollLeft*(T.options.isRtl?-1:1):t.scrollTop;T.scrollOffset!==null&&Math.abs(T.scrollOffset-e)>1&&(T.scrollOffset=e,T.scrollAdjustments=0,T.maybeNotify())}return e}),h(()=>{F(T)});let R=(0,l.useSyncExternalStore)(O,P);return(0,l.useInsertionEffect)(()=>{E.current.hasValue=!0,E.current.value=R},[R]),R};Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return P}}); \ No newline at end of file diff --git a/src/lib/useVirtualizerBase.ts b/src/lib/useVirtualizerBase.ts -index 59f46c47fe9bf8c09b482e110a623fbff5e65da3..1cb263dbc0c5f39c30f380886b4a7c19d6cee1b5 100644 +index 59f46c47fe9bf8c09b482e110a623fbff5e65da3..79e58c6c52b84f489c23100aba238f2c57c44db8 100644 --- a/src/lib/useVirtualizerBase.ts +++ b/src/lib/useVirtualizerBase.ts -@@ -364,14 +364,42 @@ export const useVirtualizerBase = < +@@ -49,20 +49,18 @@ export type Selector< + const getVirtualizerSnapshot = < + TScrollElement extends Element | Window, + TItemElement extends Element, +->({ +- getVirtualItems, +- getVirtualIndexes, +- getTotalSize, +- ...instance +-}: ReactVirtualizer): ReactVirtualizerSnapshot< +- TScrollElement, +- TItemElement +-> => ({ +- ...instance, +- virtualItems: getVirtualItems(), +- virtualIndexes: getVirtualIndexes(), +- totalSize: getTotalSize(), +-}) ++>( ++ instance: ReactVirtualizer, ++): ReactVirtualizerSnapshot => { ++ // InvokeAI patch: the getters update memoized instance fields (measurements, range), so read them before ++ // copying the instance; otherwise the next snapshot differs and getSnapshot looks uncached to React. ++ const virtualItems = instance.getVirtualItems() ++ const virtualIndexes = instance.getVirtualIndexes() ++ const totalSize = instance.getTotalSize() ++ const { getVirtualItems, getVirtualIndexes, getTotalSize, ...rest } = instance ++ ++ return { ...rest, virtualItems, virtualIndexes, totalSize } ++} + + const isVirtualizerSnapshotEqual = < + TScrollElement extends Element | Window, +@@ -364,14 +362,42 @@ export const useVirtualizerBase = < useLayoutEffect(() => { instance.setOptions(resolvedOptions) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 8406850cc22..e8a49c4183f 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,17 +2,17 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 793351, + "brotliBytes": 794977, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 792436, + "gzipBytes": 793689, "imageRawBytes": 44398, - "initialRawBytes": 2039244, + "initialRawBytes": 2042114, "largestAssetRawBytes": 749029, "otherAssetRawBytes": 0, - "ownedRawBytes": 98108, - "requestCount": 36, - "scriptRequestCount": 20, + "ownedRawBytes": 98621, + "requestCount": 35, + "scriptRequestCount": 19, "sourceOwners": [ "package:@ark-ui/react", "package:@chakra-ui/react", @@ -346,17 +346,17 @@ }, "editor": { "baseline": { - "brotliBytes": 1163132, + "brotliBytes": 1171873, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1156783, + "gzipBytes": 1165072, "imageRawBytes": 44398, - "initialRawBytes": 3183475, + "initialRawBytes": 3208701, "largestAssetRawBytes": 749029, "otherAssetRawBytes": 0, - "ownedRawBytes": 149407, - "requestCount": 73, - "scriptRequestCount": 57, + "ownedRawBytes": 150716, + "requestCount": 72, + "scriptRequestCount": 56, "sourceOwners": [ "package:@ark-ui/react", "package:@chakra-ui/react", @@ -1092,7 +1092,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-10-06", + "capturedAt": "2026-10-09", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 2366ac7ca91..99f91f948c9 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", - "capturedAt": "2026-10-06", + "capturedAt": "2026-10-09", "routes": [ { "activatedResourceBaseline": { @@ -14,13 +14,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 124.29999999981374, + "domContentLoadedMedianMs": 130.60000000149012, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 124.40000000037253, - "longestTaskMaxMs": 79, + "loadMedianMs": 130.79999999701977, + "longestTaskMaxMs": 82, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -30,13 +30,13 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, - "requestCount": 31, - "scriptRawBytes": 1838770, - "scriptRequestCount": 27, - "totalRawBytes": 2061474 + "otherRawBytes": 200480, + "requestCount": 30, + "scriptRawBytes": 1842561, + "scriptRequestCount": 26, + "totalRawBytes": 2068864 }, - "routeReadyMedianMs": 260.70000000018626, + "routeReadyMedianMs": 275.80000000447035, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 121.1000000005588, + "domContentLoadedMedianMs": 125.10000000149012, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 121.29999999981374, - "longestTaskMaxMs": 80, + "loadMedianMs": 125.20000000298023, + "longestTaskMaxMs": 81, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -68,13 +68,13 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, - "requestCount": 31, - "scriptRawBytes": 1838770, - "scriptRequestCount": 27, - "totalRawBytes": 2061474 + "otherRawBytes": 200480, + "requestCount": 30, + "scriptRawBytes": 1842561, + "scriptRequestCount": 26, + "totalRawBytes": 2068864 }, - "routeReadyMedianMs": 269.09999999962747, + "routeReadyMedianMs": 277, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,15 +90,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 122.30000000074506, + "domContentLoadedMedianMs": 125.5, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 122.40000000037253, - "longestTaskMaxMs": 153, + "loadMedianMs": 125.70000000298023, + "longestTaskMaxMs": 173, "owner": "workbench", - "projectSwitchMedianMs": 34.700000000186265, + "projectSwitchMedianMs": 38.30000000447035, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -106,13 +106,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 88, - "scriptRawBytes": 4005108, + "scriptRawBytes": 4063609, "scriptRequestCount": 83, - "totalRawBytes": 4272210 + "totalRawBytes": 4334310 }, - "routeReadyMedianMs": 407.5, + "routeReadyMedianMs": 430.19999999552965, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,15 +128,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 122.5, + "domContentLoadedMedianMs": 129.60000000149012, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 122.59999999962747, - "longestTaskMaxMs": 164, + "loadMedianMs": 129.69999999552965, + "longestTaskMaxMs": 180, "owner": "workbench", - "projectSwitchMedianMs": 41, + "projectSwitchMedianMs": 44.099999994039536, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -144,13 +144,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 88, - "scriptRawBytes": 4005108, + "scriptRawBytes": 4063609, "scriptRequestCount": 83, - "totalRawBytes": 4272210 + "totalRawBytes": 4334310 }, - "routeReadyMedianMs": 434.30000000074506, + "routeReadyMedianMs": 466.3999999985099, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,20 +159,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 309288, + "largestAssetRawBytes": 305162, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 515489, + "scriptRawBytes": 514518, "scriptRequestCount": 9, - "totalRawBytes": 515489 + "totalRawBytes": 514518 }, - "domContentLoadedMedianMs": 124.90000000037253, + "domContentLoadedMedianMs": 125.5, "id": "editor-canvas", - "layoutAckMedianMs": 2.699999999254942, - "layoutReturnSwitchMedianMs": 54.10000000055879, - "layoutSwitchMedianMs": 68.30000000074506, - "loadMedianMs": 125, - "longestTaskMaxMs": 157, + "layoutAckMedianMs": 2.6999999955296516, + "layoutReturnSwitchMedianMs": 57.899999998509884, + "layoutSwitchMedianMs": 76.60000000149012, + "loadMedianMs": 125.60000000149012, + "longestTaskMaxMs": 179, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -182,13 +182,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 97, - "scriptRawBytes": 4520597, + "scriptRawBytes": 4578127, "scriptRequestCount": 92, - "totalRawBytes": 4787699 + "totalRawBytes": 4848828 }, - "routeReadyMedianMs": 1119.7999999998137, + "routeReadyMedianMs": 1153.7999999970198, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 309288, + "largestAssetRawBytes": 305162, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 515489, + "scriptRawBytes": 514518, "scriptRequestCount": 9, - "totalRawBytes": 515489 + "totalRawBytes": 514518 }, - "domContentLoadedMedianMs": 122.79999999981374, + "domContentLoadedMedianMs": 126.59999999403954, "id": "editor-canvas", - "layoutAckMedianMs": 2.5, - "layoutReturnSwitchMedianMs": 47.10000000055879, - "layoutSwitchMedianMs": 64.29999999981374, - "loadMedianMs": 122.90000000037253, - "longestTaskMaxMs": 174, + "layoutAckMedianMs": 3.100000001490116, + "layoutReturnSwitchMedianMs": 52.70000000298023, + "layoutSwitchMedianMs": 72.20000000298023, + "loadMedianMs": 126.69999999552965, + "longestTaskMaxMs": 196, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -220,13 +220,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 97, - "scriptRawBytes": 4520597, + "scriptRawBytes": 4578127, "scriptRequestCount": 92, - "totalRawBytes": 4787699 + "totalRawBytes": 4848828 }, - "routeReadyMedianMs": 1104.7999999998137, + "routeReadyMedianMs": 1147.1000000014901, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187139, + "largestAssetRawBytes": 187094, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420972, + "scriptRawBytes": 420919, "scriptRequestCount": 6, - "totalRawBytes": 436385 + "totalRawBytes": 436332 }, - "domContentLoadedMedianMs": 120, + "domContentLoadedMedianMs": 125, "id": "editor-workflow", - "layoutAckMedianMs": 2.599999999627471, - "layoutReturnSwitchMedianMs": 32.59999999962747, - "layoutSwitchMedianMs": 66.19999999925494, - "loadMedianMs": 120.1000000005588, - "longestTaskMaxMs": 153, + "layoutAckMedianMs": 2.7000000029802322, + "layoutReturnSwitchMedianMs": 38.600000001490116, + "layoutSwitchMedianMs": 82.60000000149012, + "loadMedianMs": 125.09999999403954, + "longestTaskMaxMs": 174, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -258,13 +258,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 95, - "scriptRawBytes": 4426080, + "scriptRawBytes": 4484528, "scriptRequestCount": 89, - "totalRawBytes": 4708595 + "totalRawBytes": 4770642 }, - "routeReadyMedianMs": 1115.8999999994412, + "routeReadyMedianMs": 1171.6000000014901, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187139, + "largestAssetRawBytes": 187094, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420972, + "scriptRawBytes": 420919, "scriptRequestCount": 6, - "totalRawBytes": 436385 + "totalRawBytes": 436332 }, - "domContentLoadedMedianMs": 122, + "domContentLoadedMedianMs": 123.70000000298023, "id": "editor-workflow", - "layoutAckMedianMs": 2.7999999998137355, - "layoutReturnSwitchMedianMs": 40.200000000186265, - "layoutSwitchMedianMs": 123.5, - "loadMedianMs": 122.20000000018626, - "longestTaskMaxMs": 260, + "layoutAckMedianMs": 2.6999999955296516, + "layoutReturnSwitchMedianMs": 41.80000000447035, + "layoutSwitchMedianMs": 128.80000000447035, + "loadMedianMs": 123.80000000447035, + "longestTaskMaxMs": 259, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -296,13 +296,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 95, - "scriptRawBytes": 4426080, + "scriptRawBytes": 4484528, "scriptRequestCount": 89, - "totalRawBytes": 4708595 + "totalRawBytes": 4770642 }, - "routeReadyMedianMs": 1172.0999999996275, + "routeReadyMedianMs": 1192.8999999985099, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 124.09999999962747, + "domContentLoadedMedianMs": 125.79999999701977, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 30.40000000037253, - "loadMedianMs": 124.29999999981374, - "longestTaskMaxMs": 162, + "layoutSwitchMedianMs": 41.100000001490116, + "loadMedianMs": 125.89999999850988, + "longestTaskMaxMs": 174, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -334,13 +334,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 88, - "scriptRawBytes": 4005108, + "scriptRawBytes": 4063609, "scriptRequestCount": 83, - "totalRawBytes": 4272210 + "totalRawBytes": 4334310 }, - "routeReadyMedianMs": 1344, + "routeReadyMedianMs": 1431.3999999985099, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 121.40000000037253, + "domContentLoadedMedianMs": 126.89999999850988, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 37.80000000074506, - "loadMedianMs": 121.5, - "longestTaskMaxMs": 152, + "layoutSwitchMedianMs": 45.399999998509884, + "loadMedianMs": 127.09999999403954, + "longestTaskMaxMs": 170, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -372,13 +372,13 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 196881, + "otherRawBytes": 200480, "requestCount": 88, - "scriptRawBytes": 4005108, + "scriptRawBytes": 4063609, "scriptRequestCount": 83, - "totalRawBytes": 4272210 + "totalRawBytes": 4334310 }, - "routeReadyMedianMs": 1342.5, + "routeReadyMedianMs": 1396.3999999985099, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } diff --git a/invokeai/frontend/webv2/pnpm-lock.yaml b/invokeai/frontend/webv2/pnpm-lock.yaml index 18eb04ddd00..a9389336e71 100644 --- a/invokeai/frontend/webv2/pnpm-lock.yaml +++ b/invokeai/frontend/webv2/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: react-hook-tanstack-virtual: - hash: 2d69c3f291348e61b3d4e3f96732527872259fee1c4df92376fff4ebc8432072 + hash: 6e0c46d85d50489bc6f6a0350c74c496a26fb3652c08061377650d9a40872da0 path: patches/react-hook-tanstack-virtual.patch importers: @@ -84,7 +84,7 @@ importers: version: 19.3.0(react@19.3.0) react-hook-tanstack-virtual: specifier: ^0.0.4 - version: 0.0.4(patch_hash=2d69c3f291348e61b3d4e3f96732527872259fee1c4df92376fff4ebc8432072)(@tanstack/react-virtual@3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/virtual-core@3.17.11)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.0.4(patch_hash=6e0c46d85d50489bc6f6a0350c74c496a26fb3652c08061377650d9a40872da0)(@tanstack/react-virtual@3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/virtual-core@3.17.11)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react-i18next: specifier: ^17.0.14 version: 17.0.14(i18next@26.4.2(typescript@7.0.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) @@ -4416,7 +4416,7 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 - react-hook-tanstack-virtual@0.0.4(patch_hash=2d69c3f291348e61b3d4e3f96732527872259fee1c4df92376fff4ebc8432072)(@tanstack/react-virtual@3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/virtual-core@3.17.11)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + react-hook-tanstack-virtual@0.0.4(patch_hash=6e0c46d85d50489bc6f6a0350c74c496a26fb3652c08061377650d9a40872da0)(@tanstack/react-virtual@3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/virtual-core@3.17.11)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@tanstack/react-virtual': 3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/virtual-core': 3.17.11 diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index 4cf34e836e5..2e41a154151 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -270,8 +270,14 @@ test('merged gallery list and names share qualified ordering, filters, ownership backend, '/api/v1/gallery/items/?categories=general&is_intermediate=false&order_dir=DESC&limit=100&offset=0' ); + const countOnlyPage = await getJson( + backend, + '/api/v1/gallery/items/?categories=general&is_intermediate=false&order_dir=DESC&limit=0&offset=0' + ); assert.equal(defaultPage.items[0]?.kind, 'image'); + assert.deepEqual(countOnlyPage.items, []); + assert.equal(countOnlyPage.total, backendDefaultPage.total); assert.deepEqual( backendDefaultPage.items.map((item) => item.starred), backendDefaultPage.items.map((item) => item.starred).toSorted((left, right) => Number(right) - Number(left)) @@ -333,6 +339,37 @@ test('merged gallery list and names share qualified ordering, filters, ownership }); }); +test('gallery item location shares listing filters and order, and reports missing items as not found', async () => { + await withRepresentativeBackend(async (backend) => { + const query = + 'categories=general&is_intermediate=false&origin=internal&starred=false&order_dir=ASC&starred_first=false&created_from=2026-01-15&created_to=2026-01-15'; + const listing = await getJson(backend, `/api/v1/gallery/items/?${query}&limit=1000&offset=0`); + const target = listing.items.find((item) => item.kind === 'video'); + + assert.ok(target, 'the filtered fixture listing should contain a video'); + + const location = await getJson( + backend, + `/api/v1/gallery/items/location?${query}&kind=${target.kind}&name=${encodeURIComponent(target.name)}` + ); + + assert.deepEqual(location, { + index: listing.items.findIndex((item) => item.kind === target.kind && item.name === target.name), + kind: target.kind, + name: target.name, + total: listing.total, + }); + + const excludedQuery = query.replace('starred=false', 'starred=true'); + const notFound = await fetch( + `${backend.origin}/api/v1/gallery/items/location?${excludedQuery}&kind=${target.kind}&name=${encodeURIComponent(target.name)}` + ); + + assert.equal(notFound.status, 404); + assert.deepEqual(await notFound.json(), { detail: 'Gallery item not found' }); + }); +}); + test('date-board item names preserve the mixed gallery ordering and filter contract', async () => { await withRepresentativeBackend(async (backend) => { const result = await getJson( diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 50088a74d83..23e21246458 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -371,10 +371,18 @@ const toGalleryItem = (kind, value) => }; const getGalleryCandidates = (state) => [ - ...[...state.images.values()].map((image) => ({ item: toGalleryItem('image', image), searchable: image.metadata })), + ...[...state.images.values()].map((image) => ({ + item: toGalleryItem('image', image), + origin: image.image_origin ?? 'internal', + searchable: image.metadata, + })), ...[...state.videos.values()] .filter((video) => video.owner_user_id === MOCK_USER_ID) - .map((video) => ({ item: toGalleryItem('video', video), searchable: video.metadata })), + .map((video) => ({ + item: toGalleryItem('video', video), + origin: video.video_origin ?? 'internal', + searchable: video.metadata, + })), ]; const compareGalleryItems = (left, right, orderDir, starredFirst) => { @@ -391,20 +399,24 @@ const compareGalleryItems = (left, right, orderDir, starredFirst) => { ); }; -const filterGalleryItems = (state, url, { createdDate } = {}) => { +const filterGalleryItems = (state, url, { createdDate, starredFirst } = {}) => { const boardId = url.searchParams.get('board_id'); const categories = getRequestedCategories(url); const createdFrom = url.searchParams.get('created_from'); const createdTo = url.searchParams.get('created_to'); const intermediate = getOptionalBoolean(url, 'is_intermediate'); + const origin = url.searchParams.get('origin'); const starred = getOptionalBoolean(url, 'starred'); const searchTerm = url.searchParams.get('search_term')?.trim().toLocaleLowerCase() ?? ''; return getGalleryCandidates(state) - .filter(({ item, searchable }) => { + .filter(({ item, origin: itemOrigin, searchable }) => { if (boardId && (boardId === 'none' ? item.board_id !== null : item.board_id !== boardId)) { return false; } + if (origin && itemOrigin !== origin) { + return false; + } if (categories.length > 0 && !categories.includes(item.category)) { return false; } @@ -439,11 +451,20 @@ const filterGalleryItems = (state, url, { createdDate } = {}) => { left, right, url.searchParams.get('order_dir')?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC', - url.searchParams.get('starred_first') !== 'false' + starredFirst ?? url.searchParams.get('starred_first') !== 'false' ) ); }; +const getGalleryItemLocation = (state, url) => { + const kind = url.searchParams.get('kind'); + const name = url.searchParams.get('name'); + const items = filterGalleryItems(state, url, { starredFirst: false }); + const index = items.findIndex((item) => item.kind === kind && item.name === name); + + return index === -1 ? null : { kind, name, index, total: items.length }; +}; + const listGalleryItems = (state, url) => { const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0); const limit = Math.max(0, Number(url.searchParams.get('limit') ?? 10) || 0); @@ -1757,6 +1778,12 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, listGalleryItems(state, url)); } + if (method === 'GET' && path === '/api/v1/gallery/items/location') { + const location = getGalleryItemLocation(state, url); + + return location ? json(200, location) : json(404, { detail: 'Gallery item not found' }); + } + if (method === 'GET' && path === '/api/v1/gallery/items/names') { return json(200, listGalleryItemNames(state, url)); } diff --git a/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx b/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx index 2f2d7107c98..adb27ca8fc7 100644 --- a/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/GalleryUiAdapter.tsx @@ -55,6 +55,16 @@ export const GalleryUiAdapterProvider = ({ children }: { children: ReactNode }) exportProject, gallery: { ...gallery, + selectItem: (item, selectionPage) => + selectionPage === undefined ? gallery.selectItem(item) : gallery.selectItem(item, undefined, selectionPage), + setItemMultiSelection: (itemKeys, primaryItem, selectionPage) => + selectionPage === undefined + ? gallery.setItemMultiSelection(itemKeys, primaryItem) + : gallery.setItemMultiSelection(itemKeys, primaryItem, undefined, selectionPage), + toggleItemSelection: (item, nextPrimaryItem, selectionPage) => + selectionPage === undefined + ? gallery.toggleItemSelection(item, nextPrimaryItem) + : gallery.toggleItemSelection(item, nextPrimaryItem, undefined, selectionPage), updateSettings: (settings) => { if (isAccountScopeCurrent(accountScope) && queries.isActiveProject(projectId)) { gallery.updateSettings(settings, projectId); diff --git a/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx b/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx index a4c939a3be2..80ce1508888 100644 --- a/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx +++ b/invokeai/frontend/webv2/src/app/VideoUiAdapter.browser.test.tsx @@ -197,7 +197,11 @@ describe('findInGallery', () => { await vi.waitFor(() => expect(revealGalleryItem).toHaveBeenCalledTimes(1)); - expect(revealGalleryItem.mock.calls[0]?.[2]).toEqual({ projectId: 'project-1', sequence: before + 1 }); + expect(revealGalleryItem.mock.calls[0]?.[2]).toMatchObject({ + accountScope: { epoch: expect.any(Number), signal: expect.any(AbortSignal) }, + projectId: 'project-1', + sequence: before + 1, + }); expect(afterPress).toBe(before + 2); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/core/items.ts b/invokeai/frontend/webv2/src/features/gallery/core/items.ts index 6f827becce0..082c32a1104 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/items.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/items.ts @@ -43,6 +43,10 @@ export type GalleryItem = GalleryImageItem | GalleryVideoItem; export interface GalleryItemsPage { items: GalleryItem[]; + /** Absolute list offset requested for this page. Omitted only by legacy fixtures/cache entries. */ + offset?: number; + /** Absolute slot for each item, kept aligned with `items`; sparse hydration leaves index gaps. */ + itemIndices?: number[]; total: number; } diff --git a/invokeai/frontend/webv2/src/features/gallery/core/revealRequest.selection.test.ts b/invokeai/frontend/webv2/src/features/gallery/core/revealRequest.selection.test.ts index 7d1d2801842..e559a3e2e8c 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/revealRequest.selection.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/revealRequest.selection.test.ts @@ -1,16 +1,25 @@ -import { describe, expect, it, vi } from 'vitest'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getGalleryRevealRequest, requestGalleryItemReveal, subscribeGalleryRevealRequests } from './selection'; describe('gallery reveal requests', () => { + beforeEach(() => { + accountLifecycle.activate('gallery-reveal-request-test'); + }); + + afterEach(() => { + accountLifecycle.invalidate(); + }); + it('notifies subscribers with a fresh token per request, even for the same item', () => { const listener = vi.fn(); const unsubscribe = subscribeGalleryRevealRequests(listener); - requestGalleryItemReveal('image:a.png'); + requestGalleryItemReveal('image:a.png', accountLifecycle.capture().signal); const first = getGalleryRevealRequest(); - requestGalleryItemReveal('image:a.png'); + requestGalleryItemReveal('image:a.png', accountLifecycle.capture().signal); const second = getGalleryRevealRequest(); expect(listener).toHaveBeenCalledTimes(2); @@ -21,7 +30,38 @@ describe('gallery reveal requests', () => { expect(second?.token).not.toBe(first?.token); unsubscribe(); - requestGalleryItemReveal('image:b.png'); + requestGalleryItemReveal('image:b.png', accountLifecycle.capture().signal); expect(listener).toHaveBeenCalledTimes(2); }); + + it('preserves an optional absolute index for a verified deep reveal', () => { + requestGalleryItemReveal('image:deep.png', accountLifecycle.capture().signal, 6073); + + expect(getGalleryRevealRequest()).toMatchObject({ absoluteIndex: 6073, itemKey: 'image:deep.png' }); + }); + + it('hides an account-owned request when Gallery reads it after an account rotation', () => { + const listener = vi.fn(); + const unsubscribe = subscribeGalleryRevealRequests(listener); + + const accountA = accountLifecycle.activate('gallery-reveal-request-owner'); + requestGalleryItemReveal('image:account-a.png', accountA.signal); + const request = getGalleryRevealRequest(); + + accountLifecycle.activate('gallery-reveal-request-next-owner'); + + expect(request).toMatchObject({ accountSignal: accountA.signal, itemKey: 'image:account-a.png' }); + expect(getGalleryRevealRequest()).toBeNull(); + + requestGalleryItemReveal('image:account-b.png', accountLifecycle.capture().signal); + + expect(getGalleryRevealRequest()).toMatchObject({ + accountSignal: accountLifecycle.capture().signal, + itemKey: 'image:account-b.png', + }); + expect(getGalleryRevealRequest()?.token).toBeGreaterThan(request?.token ?? 0); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/core/selection.test.ts b/invokeai/frontend/webv2/src/features/gallery/core/selection.test.ts index 4ed2ea45b76..e90b02de8c1 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/selection.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/selection.test.ts @@ -209,7 +209,7 @@ describe('getGalleryNavigationStep', () => { options ); - return next === null ? null : next.kind === 'session' ? next.id : next.item.name; + return next === null ? null : next.kind === 'item' ? next.item.name : next.id; }; it('walks left and right across every seam, skipping tiles that cannot be followed', () => { @@ -297,4 +297,13 @@ describe('getGalleryNavigationStep', () => { // With no cursor shown, the first item rather than the first session. expect(step(null, 'right', { itemsOnly: true })).toBe('s0'); }); + + it('treats unloaded gallery slots as item positions in range and focus navigation', () => { + const unloaded = { id: 'gallery-unloaded-slot:119', kind: 'slot' as const, navigable: true }; + const sections = [[unloaded, entry('page-120')]]; + + expect(selection.getGalleryNavigationStep(sections, ['image:page-120'], 'left', 3, { itemsOnly: true })).toEqual( + unloaded + ); + }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/core/selection.ts b/invokeai/frontend/webv2/src/features/gallery/core/selection.ts index c84edd7ebf4..e5a2286156e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/selection.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/selection.ts @@ -136,6 +136,8 @@ export const getGalleryDeletionSuccessor = ( */ export interface GalleryRevealRequest { + accountSignal: AbortSignal; + absoluteIndex?: number; itemKey: GalleryItemKey; token: number; } @@ -145,15 +147,24 @@ let nextToken = 0; const listeners = new Set<() => void>(); -export const requestGalleryItemReveal = (itemKey: GalleryItemKey): void => { +export const requestGalleryItemReveal = ( + itemKey: GalleryItemKey, + accountSignal: AbortSignal, + absoluteIndex?: number +): void => { nextToken += 1; - currentRequest = { itemKey, token: nextToken }; + currentRequest = { accountSignal, absoluteIndex, itemKey, token: nextToken }; for (const listener of listeners) { listener(); } }; -export const getGalleryRevealRequest = (): GalleryRevealRequest | null => currentRequest; +/** Ignore a pending intent after its account lifetime changes, including when Gallery mounts later. */ +export const getGalleryRevealRequest = (): GalleryRevealRequest | null => { + const request = currentRequest; + + return request && !request.accountSignal.aborted ? request : null; +}; export const subscribeGalleryRevealRequests = (listener: () => void): (() => void) => { listeners.add(listener); @@ -168,6 +179,33 @@ export const subscribeGalleryRevealRequests = (listener: () => void): (() => voi * shared selection. */ +export interface GalleryLocatorRequest { + signal: AbortSignal; + release: () => void; +} + +const activeGalleryLocatorRequests = new Set(); + +/** Create a cancellable locator lifetime shared by Gallery reveal entry points. */ +export const createGalleryLocatorRequest = (): GalleryLocatorRequest => { + const controller = new AbortController(); + activeGalleryLocatorRequests.add(controller); + + return { + signal: controller.signal, + release: () => activeGalleryLocatorRequests.delete(controller), + }; +}; + +/** A later Gallery navigation supersedes every locator currently in flight. */ +export const abortGalleryLocatorRequests = (): void => { + for (const controller of activeGalleryLocatorRequests) { + controller.abort(); + } + + activeGalleryLocatorRequests.clear(); +}; + let navigationSequence = 0; /** Claims this navigation's place in the global ordering; every later claim supersedes it. */ @@ -183,6 +221,7 @@ export const isGalleryNavigationCurrent = (sequence: number): boolean => sequenc export type GalleryNavigationEntry = | { kind: 'item'; item: GalleryItem } + | { kind: 'slot'; id: string; navigable: boolean } | { kind: 'session'; id: string; navigable: boolean }; export type GalleryNavigationDirection = 'down' | 'left' | 'right' | 'up'; @@ -190,7 +229,11 @@ export type GalleryNavigationDirection = 'down' | 'left' | 'right' | 'up'; export const getGallerySessionNavigationKey = (sessionId: string): string => `session:${sessionId}`; const getGalleryNavigationEntryKey = (entry: GalleryNavigationEntry): string => - entry.kind === 'item' ? toGalleryItemKey(entry.item) : getGallerySessionNavigationKey(entry.id); + entry.kind === 'item' + ? toGalleryItemKey(entry.item) + : entry.kind === 'slot' + ? entry.id + : getGallerySessionNavigationKey(entry.id); /** * Where an arrow steps from: the first candidate the sections show. A candidate they do not show (a followed session @@ -219,7 +262,10 @@ export const getGalleryNavigationStep = ( { itemsOnly = false }: { itemsOnly?: boolean } = {} ): GalleryNavigationEntry | null => { const isNavigable = (entry: GalleryNavigationEntry | undefined): entry is GalleryNavigationEntry => - entry !== undefined && (entry.kind === 'item' || (!itemsOnly && entry.navigable)); + entry !== undefined && + (entry.kind === 'item' || + (entry.kind === 'slot' && entry.navigable) || + (entry.kind === 'session' && !itemsOnly && entry.navigable)); const entries = sections.flat(); const cursorKey = getGalleryNavigationCursor(sections, cursorKeys); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts index a6c7aa609aa..5f046e8ce70 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.test.ts @@ -24,6 +24,7 @@ import { fetchImageIndexAvailability, getGalleryImageByName, getGalleryImagesByNames, + getGalleryItemLocation, getGalleryVideoMetadata, getGalleryImageWorkflow, getGalleryVideoWorkflow, @@ -160,6 +161,38 @@ describe('downloadGalleryArchive', () => { }); }); +describe('getGalleryItemLocation', () => { + beforeEach(() => { + accountLifecycle.activate('user-a'); + mocks.apiFetchJson.mockReset(); + }); + + it('sends the exact ordinary-page filters and maps the zero-based rank', async () => { + mocks.apiFetchJson.mockResolvedValue({ index: 123, kind: 'video', name: 'clip.mp4', total: 250 }); + const signal = new AbortController().signal; + + await expect( + getGalleryItemLocation({ + boardId: 'board-1', + createdFrom: '2026-07-01', + createdTo: '2026-07-31', + galleryView: 'assets', + kind: 'video', + name: 'clip.mp4', + orderDir: 'ASC', + searchTerm: ' portrait ', + signal, + starred: false, + }) + ).resolves.toEqual({ index: 123, kind: 'video', name: 'clip.mp4', total: 250 }); + + expect(mocks.apiFetchJson).toHaveBeenCalledWith( + '/api/v1/gallery/items/location?board_id=board-1&categories=control&categories=mask&categories=user&created_from=2026-07-01&created_to=2026-07-31&is_intermediate=false&kind=video&order_dir=ASC&name=clip.mp4&search_term=portrait&starred=false&starred_first=false', + { signal } + ); + }); +}); + describe('getGalleryImageByName', () => { beforeEach(() => { mocks.apiFetchJson.mockReset(); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts index f1bd5d043f1..e817572d7d8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts @@ -1,3 +1,4 @@ +import type { components } from '@api/schema'; import type { GalleryImageItem, GalleryItem, @@ -545,6 +546,8 @@ export interface GalleryItemNames { total: number; } +export type GalleryItemLocation = components['schemas']['GalleryItemLocation']; + interface GalleryItemNamesRequest { boardId: string; createdFrom?: string; @@ -593,6 +596,37 @@ export const listGalleryItemNames = async ({ return mapGalleryItemNames(body); }; +/** Resolve one ordinary listing position without downloading every name in the listing. */ +export const getGalleryItemLocation = ({ + boardId, + createdFrom, + createdTo, + galleryView, + kind, + name, + orderDir, + searchTerm, + signal, + starred, +}: GalleryItemNamesRequest & GalleryItemRef): Promise => { + const query = toSearchParams({ + board_id: boardId, + categories: galleryView === 'assets' ? assetCategories : imageCategories, + created_from: createdFrom, + created_to: createdTo, + is_intermediate: false, + kind, + order_dir: orderDir, + name, + search_term: searchTerm.trim() || undefined, + starred, + // Match the Gallery's chronological page query, which never uses the API's default starred-first ordering. + starred_first: false, + }); + + return apiFetchJson(`/api/v1/gallery/items/location?${query}`, { signal }); +}; + export const listGalleryDateBoardItemNames = async ({ boardId, createdFrom, @@ -675,13 +709,19 @@ export const hydrateGalleryDateBoardItemPage = async ({ hydrateVideoRefs(refs, signal), ]); const imagesByName = new Map(images.map((image) => [image.name, image])); - const hydrated = refs.flatMap((ref, index) => { + const hydrated: GalleryItem[] = []; + const itemIndices: number[] = []; + + refs.forEach((ref, index) => { const item = ref.kind === 'image' ? imagesByName.get(ref.name) : videosByIndex.get(index); - return item ? [item] : []; + if (item) { + hydrated.push(item); + itemIndices.push(offset + index); + } }); - return { items: hydrated, total }; + return { items: hydrated, itemIndices, offset, total }; }; const hydratePaletteDateBoardImagePage = async ({ diff --git a/invokeai/frontend/webv2/src/features/gallery/data/dateBoardItemHydration.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/dateBoardItemHydration.test.ts index 36b8a4d399e..2c741a9cf1f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/dateBoardItemHydration.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/dateBoardItemHydration.test.ts @@ -166,7 +166,7 @@ describe('Gallery item names and date hydration', () => { expect(mocks.apiFetchJson.mock.calls.filter(([url]) => url === '/api/v1/images/images_by_names')).toHaveLength(1); }); - it('omits only 404 video refs while preserving surrounding order', async () => { + it('preserves absolute ref slots when video and image hydration omit missing refs', async () => { mocks.apiFetchJson.mockImplementation((url: string) => { if (url === '/api/v1/images/images_by_names') { return Promise.resolve([imageDto('still')]); @@ -179,18 +179,23 @@ describe('Gallery item names and date hydration', () => { const page = await hydrateGalleryDateBoardItemPage({ items: [ + { kind: 'image', name: 'outside-0' }, + { kind: 'image', name: 'outside-1' }, + { kind: 'video', name: 'outside-2' }, + { kind: 'video', name: 'outside-3' }, { kind: 'video', name: 'first' }, { kind: 'video', name: 'missing' }, + { kind: 'image', name: 'missing-image' }, { kind: 'image', name: 'still' }, { kind: 'video', name: 'last' }, ], - limit: 4, - offset: 0, - total: 4, + limit: 5, + offset: 4, + total: 9, }); expect(page.items.map(({ kind, name }) => `${kind}:${name}`)).toEqual(['video:first', 'image:still', 'video:last']); - expect(page.total).toBe(4); + expect(page).toMatchObject({ itemIndices: [4, 7, 8], offset: 4, total: 9 }); }); it.each([ diff --git a/invokeai/frontend/webv2/src/features/gallery/data/locationQueries.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/locationQueries.test.ts new file mode 100644 index 00000000000..081b99e7006 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/data/locationQueries.test.ts @@ -0,0 +1,444 @@ +import type { GalleryItem, GalleryItemsPage } from '@features/gallery/core/items'; + +import { abortGalleryLocatorRequests, createGalleryLocatorRequest } from '@features/gallery/core/selection'; +import { AccountScopeExpiredError, accountLifecycle } from '@platform/state/accountLifecycle'; +import { QueryClient, QueryObserver } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const backend = vi.hoisted(() => ({ + fetchImageIndexAvailability: vi.fn(), + getGalleryItemLocation: vi.fn(), + hydrateGalleryDateBoardItemPage: vi.fn(), + isDateBoardId: vi.fn(), + listGalleryBoards: vi.fn(), + listGalleryDateBoardItemNames: vi.fn(), + listGalleryDateBoards: vi.fn(), + listGalleryItemNames: vi.fn(), + listGalleryItems: vi.fn(), + listSemanticGalleryItemNames: vi.fn(), +})); + +vi.mock('./backend', () => backend); + +import { + fetchGalleryItemsPage, + fetchVerifiedGalleryItemPage, + GALLERY_PAGE_SIZE, + galleryItemsPageOptions, + type GalleryItemsFilter, +} from './queries'; + +const filter: GalleryItemsFilter = { + boardId: 'board-1', + createdFrom: '2026-07-01', + createdTo: '2026-07-31', + galleryView: 'assets', + orderDir: 'ASC', + searchTerm: 'portrait', + starred: false, +}; + +const ref = { kind: 'video' as const, name: 'target.mp4' }; + +const createItem = (name: string): GalleryItem => ({ + boardId: 'board-1', + category: 'user', + createdAt: '2026-07-15T12:00:00Z', + durationSeconds: 4, + fullUrl: `/videos/${name}`, + height: 64, + isIntermediate: false, + kind: 'video', + name, + starred: false, + thumbnailUrl: `/videos/${name}/thumbnail`, + width: 64, +}); + +const createPage = (offset: number, total: number, index: number, name = ref.name): GalleryItemsPage => ({ + items: [createItem(name)], + itemIndices: [index], + offset, + total, +}); + +const createQueryClient = (): QueryClient => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +beforeEach(() => { + accountLifecycle.activate('gallery-location-test'); + backend.fetchImageIndexAvailability.mockReset(); + backend.getGalleryItemLocation.mockReset(); + backend.hydrateGalleryDateBoardItemPage.mockReset(); + backend.isDateBoardId.mockReset().mockReturnValue(false); + backend.listGalleryBoards.mockReset(); + backend.listGalleryDateBoardItemNames.mockReset(); + backend.listGalleryDateBoards.mockReset(); + backend.listGalleryItemNames.mockReset(); + backend.listGalleryItems.mockReset(); + backend.listSemanticGalleryItemNames.mockReset(); +}); + +afterEach(() => accountLifecycle.invalidate()); + +describe('fetchVerifiedGalleryItemPage', () => { + it('aborts locator lifetimes across Gallery navigation entry points', () => { + const first = createGalleryLocatorRequest(); + const second = createGalleryLocatorRequest(); + + abortGalleryLocatorRequests(); + + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(true); + first.release(); + second.release(); + }); + + it('uses the filtered location and fetches only the aligned 60-item page, including sparse hydrated slots', async () => { + backend.getGalleryItemLocation.mockResolvedValue({ ...ref, index: 127, total: 200 }); + backend.listGalleryItems.mockResolvedValue(createPage(120, 200, 127)); + const queryClient = createQueryClient(); + + await expect(fetchVerifiedGalleryItemPage(queryClient, filter, ref)).resolves.toMatchObject({ + index: 127, + offset: 120, + page: { itemIndices: [127], items: [{ kind: 'video', name: 'target.mp4' }] }, + total: 200, + }); + + expect(backend.getGalleryItemLocation).toHaveBeenCalledWith( + expect.objectContaining({ ...filter, ...ref, signal: expect.any(AbortSignal) }) + ); + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + expect(backend.listGalleryItems.mock.calls[0]?.[0]).toMatchObject({ limit: GALLERY_PAGE_SIZE, offset: 120 }); + expect(backend.listGalleryItemNames).not.toHaveBeenCalled(); + queryClient.clear(); + }); + + it('refreshes a cached target page before treating an external locator rank as verified', async () => { + backend.getGalleryItemLocation.mockResolvedValue({ ...ref, index: 127, total: 200 }); + backend.listGalleryItems.mockResolvedValue(createPage(120, 200, 127)); + const queryClient = createQueryClient(); + const pageOptions = galleryItemsPageOptions(filter, 120); + + queryClient.setQueryData(pageOptions.queryKey, createPage(120, 200, 127)); + await expect(fetchVerifiedGalleryItemPage(queryClient, filter, ref)).resolves.toMatchObject({ index: 127 }); + + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + queryClient.clear(); + }); + + it('re-resolves once and refetches the same page when its hydrated slot disagrees', async () => { + backend.getGalleryItemLocation + .mockResolvedValueOnce({ ...ref, index: 122, total: 200 }) + .mockResolvedValueOnce({ ...ref, index: 121, total: 200 }); + backend.listGalleryItems + .mockResolvedValueOnce(createPage(120, 200, 121, 'other.mp4')) + .mockResolvedValueOnce(createPage(120, 200, 121)); + const queryClient = createQueryClient(); + + await expect(fetchVerifiedGalleryItemPage(queryClient, filter, ref)).resolves.toMatchObject({ index: 121 }); + + expect(backend.getGalleryItemLocation).toHaveBeenCalledTimes(2); + expect(backend.listGalleryItems).toHaveBeenCalledTimes(2); + expect(backend.listGalleryItems.mock.calls.map(([request]) => request.offset)).toEqual([120, 120]); + queryClient.clear(); + }); + + it('stops after one failed alignment instead of retrying indefinitely', async () => { + backend.getGalleryItemLocation.mockResolvedValue({ ...ref, index: 61, total: 120 }); + backend.listGalleryItems.mockResolvedValue(createPage(60, 120, 60, 'other.mp4')); + const queryClient = createQueryClient(); + + await expect(fetchVerifiedGalleryItemPage(queryClient, filter, ref)).resolves.toBeNull(); + + expect(backend.getGalleryItemLocation).toHaveBeenCalledTimes(2); + expect(backend.listGalleryItems).toHaveBeenCalledTimes(2); + queryClient.clear(); + }); + + it('reads again when a gallery invalidation cancels its page read', async () => { + backend.getGalleryItemLocation.mockResolvedValue({ ...ref, index: 127, total: 200 }); + backend.listGalleryItems + .mockImplementationOnce( + () => + new Promise(() => { + // Held until the invalidation cancels it. + }) + ) + .mockResolvedValueOnce(createPage(120, 200, 127)); + const queryClient = createQueryClient(); + const verified = fetchVerifiedGalleryItemPage(queryClient, filter, ref); + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + await queryClient.cancelQueries({ queryKey: ['gallery', 'items', 'list'] }); + + await expect(verified).resolves.toMatchObject({ index: 127, offset: 120 }); + expect(backend.getGalleryItemLocation).toHaveBeenCalledTimes(2); + expect(backend.listGalleryItems).toHaveBeenCalledTimes(2); + queryClient.clear(); + }); + + it('abandons location work owned by an account epoch after that account changes', async () => { + let resolveLocation!: (location: { kind: 'video'; name: string; index: number; total: number }) => void; + backend.getGalleryItemLocation.mockReturnValue( + new Promise((resolve) => { + resolveLocation = resolve; + }) + ); + const queryClient = createQueryClient(); + const operation = fetchVerifiedGalleryItemPage(queryClient, filter, ref); + + await vi.waitFor(() => expect(backend.getGalleryItemLocation).toHaveBeenCalledOnce()); + accountLifecycle.activate('gallery-location-next-account'); + resolveLocation({ ...ref, index: 1, total: 5 }); + + await expect(operation).rejects.toBeInstanceOf(AccountScopeExpiredError); + expect(backend.listGalleryItems).not.toHaveBeenCalled(); + queryClient.clear(); + }); + + it('aborts a superseded item-location request before it can fetch a page', async () => { + let locationSignal: AbortSignal | undefined; + backend.getGalleryItemLocation.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + locationSignal = signal; + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }) + ); + const queryClient = createQueryClient(); + const locator = new AbortController(); + const request = fetchVerifiedGalleryItemPage(queryClient, filter, ref, undefined, locator.signal); + + await vi.waitFor(() => expect(backend.getGalleryItemLocation).toHaveBeenCalledOnce()); + locator.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(locationSignal?.aborted).toBe(true); + expect(backend.listGalleryItems).not.toHaveBeenCalled(); + queryClient.clear(); + }); + + it('starts a fresh same-target lookup after the prior locator is superseded', async () => { + let firstSignal: AbortSignal | undefined; + backend.getGalleryItemLocation.mockImplementation(({ signal }: { signal: AbortSignal }) => { + if (backend.getGalleryItemLocation.mock.calls.length > 1) { + return Promise.resolve({ ...ref, index: 127, total: 200 }); + } + + firstSignal = signal; + + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + backend.listGalleryItems.mockResolvedValue(createPage(120, 200, 127)); + const queryClient = createQueryClient(); + const firstLocator = new AbortController(); + const firstRequest = fetchVerifiedGalleryItemPage(queryClient, filter, ref, undefined, firstLocator.signal); + + await vi.waitFor(() => expect(backend.getGalleryItemLocation).toHaveBeenCalledOnce()); + firstLocator.abort(); + await expect(firstRequest).rejects.toMatchObject({ name: 'AbortError' }); + expect(firstSignal?.aborted).toBe(true); + + const secondRequest = fetchVerifiedGalleryItemPage(queryClient, filter, ref); + + await expect(secondRequest).resolves.toMatchObject({ index: 127, offset: 120 }); + expect(backend.getGalleryItemLocation).toHaveBeenCalledTimes(2); + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + queryClient.clear(); + }); + + it('cancels a running page when its locator is the last unobserved caller', async () => { + backend.getGalleryItemLocation.mockResolvedValue({ ...ref, index: 127, total: 200 }); + let pageSignal: AbortSignal | undefined; + let resolvePage!: (page: GalleryItemsPage) => void; + backend.listGalleryItems.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve) => { + pageSignal = signal; + resolvePage = resolve; + }) + ); + const queryClient = createQueryClient(); + const pageOptions = galleryItemsPageOptions(filter, 120); + const locator = new AbortController(); + const request = fetchVerifiedGalleryItemPage(queryClient, filter, ref, undefined, locator.signal); + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + locator.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(pageSignal?.aborted).toBe(true); + expect(backend.getGalleryItemLocation).toHaveBeenCalledOnce(); + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + expect(queryClient.getQueryData(pageOptions.queryKey)).toBeUndefined(); + resolvePage(createPage(120, 200, 127)); + queryClient.clear(); + }); + + it('keeps an unobserved Preview page caller alive when its locator is superseded', async () => { + backend.getGalleryItemLocation.mockResolvedValue({ ...ref, index: 127, total: 200 }); + let pageSignal: AbortSignal | undefined; + let resolvePage!: (page: GalleryItemsPage) => void; + backend.listGalleryItems.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve) => { + pageSignal = signal; + resolvePage = resolve; + }) + ); + const queryClient = createQueryClient(); + const pageOptions = galleryItemsPageOptions(filter, 120); + const fetchQuery = vi.spyOn(queryClient, 'fetchQuery'); + const previewRequest = new AbortController(); + const previewPage = fetchGalleryItemsPage(queryClient, filter, 120, { signal: previewRequest.signal }); + const locator = new AbortController(); + const locatorPage = fetchVerifiedGalleryItemPage(queryClient, filter, ref, undefined, locator.signal); + + try { + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: pageOptions.queryKey, staleTime: 0 }) + ) + ); + locator.abort(); + + await expect(locatorPage).rejects.toMatchObject({ name: 'AbortError' }); + expect(pageSignal?.aborted).toBe(false); + resolvePage(createPage(120, 200, 127)); + await expect(previewPage).resolves.toMatchObject({ itemIndices: [127], offset: 120 }); + expect(pageSignal?.aborted).toBe(false); + expect(queryClient.getQueryData(pageOptions.queryKey)).toMatchObject({ itemIndices: [127] }); + } finally { + previewRequest.abort(); + queryClient.clear(); + } + }); + + it('does not cancel another unobserved page fetch for an already-aborted wrapper caller', async () => { + let pageSignal: AbortSignal | undefined; + let resolvePage!: (page: GalleryItemsPage) => void; + backend.listGalleryItems.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve) => { + pageSignal = signal; + resolvePage = resolve; + }) + ); + const queryClient = createQueryClient(); + const pageOptions = galleryItemsPageOptions(filter, 120); + const otherCaller = queryClient.fetchQuery(pageOptions); + const alreadyAborted = new AbortController(); + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + alreadyAborted.abort(); + + await expect( + fetchGalleryItemsPage(queryClient, filter, 120, { signal: alreadyAborted.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(pageSignal?.aborted).toBe(false); + + resolvePage(createPage(120, 200, 127)); + await expect(otherCaller).resolves.toMatchObject({ itemIndices: [127] }); + expect(pageSignal?.aborted).toBe(false); + queryClient.clear(); + }); + + it('keeps an observed Gallery page request alive when its locator is superseded', async () => { + let resolveLocation!: (location: { kind: 'video'; name: string; index: number; total: number }) => void; + backend.getGalleryItemLocation.mockReturnValue( + new Promise((resolve) => { + resolveLocation = resolve; + }) + ); + let resolvePage!: (page: GalleryItemsPage) => void; + let pageSignal: AbortSignal | undefined; + backend.listGalleryItems.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve) => { + pageSignal = signal; + resolvePage = resolve; + }) + ); + const queryClient = createQueryClient(); + const pageOptions = galleryItemsPageOptions(filter, 120); + const observer = new QueryObserver(queryClient, pageOptions); + const unsubscribe = observer.subscribe(() => undefined); + const locator = new AbortController(); + const fetchQuery = vi.spyOn(queryClient, 'fetchQuery'); + + try { + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + const request = fetchVerifiedGalleryItemPage(queryClient, filter, ref, undefined, locator.signal); + await vi.waitFor(() => expect(backend.getGalleryItemLocation).toHaveBeenCalledOnce()); + resolveLocation({ ...ref, index: 127, total: 200 }); + await vi.waitFor(() => + expect( + queryClient.getQueryCache().find({ exact: true, queryKey: pageOptions.queryKey })?.getObserversCount() + ).toBe(1) + ); + await vi.waitFor(() => + expect(fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: pageOptions.queryKey, staleTime: 0 }) + ) + ); + locator.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(pageSignal?.aborted).toBe(false); + resolvePage(createPage(120, 200, 127)); + await vi.waitFor(() => expect(observer.getCurrentResult().data?.itemIndices).toEqual([127])); + expect(pageSignal?.aborted).toBe(false); + } finally { + unsubscribe(); + observer.destroy(); + queryClient.clear(); + } + }); + + it('removes a superseded locator page from the bounded scheduler queue when no caller remains', async () => { + const refs = Array.from({ length: 5 }, (_, index) => ({ kind: 'video' as const, name: `target-${index}.mp4` })); + backend.getGalleryItemLocation.mockImplementation(({ name }: { name: string }) => { + const index = Number(name.match(/-(\d+)\.mp4$/)?.[1] ?? 0); + + return Promise.resolve({ kind: 'video', name, index: index * GALLERY_PAGE_SIZE + 1, total: 400 }); + }); + const pendingPages: Array<{ + offset: number; + resolve: (page: GalleryItemsPage) => void; + }> = []; + backend.listGalleryItems.mockImplementation( + ({ offset }: { offset: number }) => + new Promise((resolve) => { + pendingPages.push({ offset, resolve }); + }) + ); + const queryClient = createQueryClient(); + const locators = refs.map(() => new AbortController()); + const requests = refs.map((target, index) => + fetchVerifiedGalleryItemPage(queryClient, filter, target, undefined, locators[index]?.signal).then( + (result) => ({ result }), + (error: unknown) => ({ error }) + ) + ); + + await vi.waitFor(() => expect(backend.getGalleryItemLocation).toHaveBeenCalledTimes(5)); + await vi.waitFor(() => expect(pendingPages).toHaveLength(4)); + locators[4]?.abort(); + + await expect(requests[4]).resolves.toMatchObject({ error: { name: 'AbortError' } }); + pendingPages.slice(0, 4).forEach(({ offset, resolve }) => { + const pageIndex = offset / GALLERY_PAGE_SIZE; + const target = refs[pageIndex]; + resolve(createPage(offset, 400, offset + 1, target?.name)); + }); + await expect(Promise.all(requests.slice(0, 4))).resolves.toHaveLength(4); + + expect(backend.listGalleryItems).toHaveBeenCalledTimes(4); + expect(backend.listGalleryItems.mock.calls.map(([request]) => request.offset)).toEqual([0, 60, 120, 180]); + queryClient.clear(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/pageLifecycle.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/pageLifecycle.test.ts new file mode 100644 index 00000000000..fe4eef0c460 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/data/pageLifecycle.test.ts @@ -0,0 +1,490 @@ +import type { GalleryItem, GalleryItemsPage } from '@features/gallery/core/items'; + +import { AccountScopeExpiredError, accountLifecycle } from '@platform/state/accountLifecycle'; +import { QueryClient, QueryObserver } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const backend = vi.hoisted(() => ({ listGalleryItems: vi.fn() })); + +vi.mock('./backend', () => ({ + isDateBoardId: (boardId: string) => boardId.startsWith('by_date:'), + listGalleryItems: backend.listGalleryItems, +})); + +import { planGalleryPageOffsets } from '@features/gallery/ui/galleryGridLayout'; + +import { + GALLERY_PAGE_SIZE, + fetchGalleryItemsPage, + galleryItemsPageOptions, + galleryStarredStripOptions, + type GalleryItemsFilter, +} from './queries'; + +const filter: GalleryItemsFilter = { + boardId: 'board-1', + galleryView: 'images', + orderDir: 'DESC', + searchTerm: '', +}; + +const createPage = (offset: number): GalleryItemsPage => ({ + items: Array.from({ length: GALLERY_PAGE_SIZE }, (_, index) => createItem(offset + index)), + itemIndices: Array.from({ length: GALLERY_PAGE_SIZE }, (_, index) => offset + index), + offset, + total: 6_000, +}); + +const createItem = (index: number): GalleryItem => ({ + boardId: 'board-1', + category: 'general', + createdAt: new Date(index * 1_000).toISOString(), + fullUrl: `/images/${index}`, + height: 64, + isIntermediate: false, + kind: 'image', + name: `image-${index}`, + sourceQueueItemId: 'backend-gallery', + starred: false, + thumbnailUrl: `/images/${index}/thumbnail`, + width: 64, +}); + +const createQueryClient = (): QueryClient => + new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 60_000 } } }); + +const pageQueries = (client: QueryClient) => + client + .getQueryCache() + .findAll({ queryKey: ['gallery', 'items', 'list'] }) + .filter((query) => query.queryKey[5] === 'page'); + +const fetchPage = (client: QueryClient, pageIndex: number, pageFilter = filter) => + client.fetchQuery(galleryItemsPageOptions(pageFilter, pageIndex * GALLERY_PAGE_SIZE)); + +describe('Gallery sparse page lifecycle', () => { + beforeEach(() => { + accountLifecycle.activate('gallery-page-lifecycle-test'); + backend.listGalleryItems.mockReset(); + backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => + Promise.resolve(createPage(offset)) + ); + }); + + afterEach(() => { + accountLifecycle.invalidate(); + }); + + it('retains only the ten most recently used inactive pages per listing', async () => { + const client = createQueryClient(); + + await Promise.all(Array.from({ length: 100 }, (_, index) => fetchPage(client, index))); + + const retained = pageQueries(client); + const retainedItems = retained.flatMap((query) => (query.state.data as GalleryItemsPage).items); + + expect(retained).toHaveLength(10); + expect(retained.map((query) => query.queryKey[6])).toEqual([ + 5400, 5460, 5520, 5580, 5640, 5700, 5760, 5820, 5880, 5940, + ]); + expect(retainedItems).toHaveLength(10 * GALLERY_PAGE_SIZE); + + client.clear(); + }); + + it('does not retain a page whose read fails with nothing observing it', async () => { + const client = createQueryClient(); + backend.listGalleryItems.mockRejectedValue(new Error('temporary failure')); + + for (let index = 0; index < 25; index += 1) { + await expect(fetchPage(client, index)).rejects.toThrow('temporary failure'); + } + + expect(pageQueries(client)).toHaveLength(0); + client.clear(); + }); + + it.each([37, 6_000, 600_000])('bounds retained page data while browsing a %i-item listing', async (total) => { + const client = createQueryClient(); + backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => + Promise.resolve({ + ...createPage(offset), + items: Array.from({ length: Math.min(GALLERY_PAGE_SIZE, total - offset) }, (_, index) => + createItem(offset + index) + ), + itemIndices: Array.from({ length: Math.min(GALLERY_PAGE_SIZE, total - offset) }, (_, index) => offset + index), + total, + }) + ); + + const pageCount = Math.ceil(total / GALLERY_PAGE_SIZE); + const sampledPages = Math.min(pageCount, 100); + for (let sample = 0; sample < sampledPages; sample += 1) { + const pageIndex = Math.floor((sample * (pageCount - 1)) / Math.max(1, sampledPages - 1)); + await fetchPage(client, pageIndex); + + const retained = pageQueries(client); + expect(retained).toHaveLength(Math.min(sample + 1, 10)); + expect(retained.flatMap((query) => (query.state.data as GalleryItemsPage).items).length).toBeLessThanOrEqual( + Math.min(total, 10 * GALLERY_PAGE_SIZE) + ); + } + + expect(backend.listGalleryItems).toHaveBeenCalledTimes(sampledPages); + expect(pageQueries(client).flatMap((query) => (query.state.data as GalleryItemsPage).items)).toHaveLength( + Math.min(total, 10 * GALLERY_PAGE_SIZE) + ); + if (pageCount > 10) { + expect(client.getQueryData(galleryItemsPageOptions(filter, 0).queryKey)).toBeUndefined(); + backend.listGalleryItems.mockClear(); + await fetchPage(client, 0); + expect(backend.listGalleryItems.mock.calls.map(([request]) => request.offset)).toEqual([0]); + expect(pageQueries(client)).toHaveLength(10); + } + client.clear(); + }); + + it('requests only new viewport pages for a page scroll, fling, distant jump, and backward reload', async () => { + const client = createQueryClient(); + backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => + Promise.resolve({ ...createPage(offset), total: 600_000 }) + ); + let unsubscribePrevious: Array<() => void> = []; + const browseRange = async (startIndex: number, expectedRequests: number[]) => { + const offsets = planGalleryPageOffsets({ + endIndexExclusive: startIndex + 120, + startIndex, + total: 600_000, + }); + const observers = offsets.map( + (offset) => new QueryObserver(client, { ...galleryItemsPageOptions(filter, offset), enabled: false }) + ); + const unsubscribes = observers.map((observer) => observer.subscribe(() => undefined)); + unsubscribePrevious.forEach((unsubscribe) => unsubscribe()); + backend.listGalleryItems.mockClear(); + await Promise.all(offsets.map((offset) => client.fetchQuery(galleryItemsPageOptions(filter, offset)))); + expect(backend.listGalleryItems.mock.calls.map(([request]) => request.offset)).toEqual(expectedRequests); + expect(observers.every((observer) => observer.getCurrentResult().data?.items.length === 60)).toBe(true); + expect(pageQueries(client).filter((query) => query.getObserversCount() === 0).length).toBeLessThanOrEqual(10); + unsubscribePrevious = unsubscribes; + }; + + await browseRange(0, [0, 60]); + await browseRange(60, [120]); + await browseRange(6_000, [6_000, 6_060]); + await browseRange(540_000, [540_000, 540_060]); + for (const start of [12_000, 18_000, 24_000, 30_000, 36_000]) { + await browseRange(start, [start, start + 60]); + } + expect(client.getQueryData(galleryItemsPageOptions(filter, 0).queryKey)).toBeUndefined(); + await browseRange(0, [0, 60]); + unsubscribePrevious.forEach((unsubscribe) => unsubscribe()); + expect(pageQueries(client)).toHaveLength(10); + client.clear(); + }); + + it('keeps retention limits separate for listings whose canonical filters differ', async () => { + const client = createQueryClient(); + const starredFilter: GalleryItemsFilter = { ...filter, starred: true }; + + await Promise.all([ + ...Array.from({ length: 12 }, (_, index) => fetchPage(client, index, filter)), + ...Array.from({ length: 12 }, (_, index) => fetchPage(client, index, starredFilter)), + ]); + + expect( + pageQueries(client).filter((query) => (query.queryKey[4] as { starred?: boolean }).starred === undefined) + ).toHaveLength(10); + expect( + pageQueries(client).filter((query) => (query.queryKey[4] as { starred?: boolean }).starred === true) + ).toHaveLength(10); + client.clear(); + }); + + it('protects observed pages while pruning inactive pages, then prunes after observation ends', async () => { + const client = createQueryClient(); + + await fetchPage(client, 0); + const options = galleryItemsPageOptions(filter, 0); + const observer = new QueryObserver(client, { ...options, enabled: false }); + const unsubscribe = observer.subscribe(() => undefined); + + await Promise.all(Array.from({ length: 12 }, (_, index) => fetchPage(client, index + 1))); + + const observedKey = options.queryKey; + const evictedKey = galleryItemsPageOptions(filter, GALLERY_PAGE_SIZE).queryKey; + + expect(pageQueries(client).map((query) => query.queryKey[6])).toContain(0); + expect(client.getQueryData(observedKey)?.items).toHaveLength(GALLERY_PAGE_SIZE); + expect(client.getQueryData(evictedKey)).toBeUndefined(); + expect(pageQueries(client).filter((query) => query.getObserversCount() === 0)).toHaveLength(10); + + unsubscribe(); + + expect(pageQueries(client)).toHaveLength(10); + expect(pageQueries(client).every((query) => query.getObserversCount() === 0)).toBe(true); + client.clear(); + }); + + it('retains every sparse page while more than ten pages are observed', async () => { + const client = createQueryClient(); + const offsets = Array.from({ length: 14 }, (_, index) => index * GALLERY_PAGE_SIZE); + const unsubscribes = offsets.map((offset) => + new QueryObserver(client, { ...galleryItemsPageOptions(filter, offset), enabled: false }).subscribe( + () => undefined + ) + ); + + await Promise.all(offsets.map((offset) => client.fetchQuery(galleryItemsPageOptions(filter, offset)))); + + expect(pageQueries(client)).toHaveLength(14); + expect(pageQueries(client).every((query) => query.state.data !== undefined && query.getObserversCount() > 0)).toBe( + true + ); + + unsubscribes.forEach((unsubscribe) => unsubscribe()); + expect(pageQueries(client)).toHaveLength(10); + client.clear(); + }); + + it('loads every page covering a wide visible range with at most four concurrent reads', async () => { + const client = createQueryClient(); + const startIndex = 17; + const endIndexExclusive = 757; + const offsets = planGalleryPageOffsets({ endIndexExclusive, startIndex, total: 6_000 }); + const requests: Array<{ offset: number; resolve: () => void }> = []; + let active = 0; + let peak = 0; + let started = 0; + + backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => { + started += 1; + active += 1; + peak = Math.max(peak, active); + + return new Promise((resolve) => { + requests.push({ + offset, + resolve: () => { + active -= 1; + resolve(createPage(offset)); + }, + }); + }); + }); + + const visibleOffsets = offsets; + const results = visibleOffsets.map((offset) => client.fetchQuery(galleryItemsPageOptions(filter, offset))); + + expect(visibleOffsets).toEqual(Array.from({ length: 13 }, (_, index) => index * GALLERY_PAGE_SIZE)); + await vi.waitFor(() => expect(started).toBe(4)); + for (let completed = 1; completed <= visibleOffsets.length; completed += 1) { + requests.shift()?.resolve(); + if (completed < visibleOffsets.length) { + await vi.waitFor(() => expect(started).toBe(Math.min(visibleOffsets.length, 4 + completed))); + } + } + + const pages = await Promise.all(results); + const visibleIndices = pages + .flatMap((page) => page.itemIndices ?? []) + .filter((index) => index >= startIndex && index < endIndexExclusive) + .sort((left, right) => left - right); + + expect(backend.listGalleryItems.mock.calls.map(([request]) => request.offset)).toEqual(visibleOffsets); + expect(pages.map((page) => page.offset)).toEqual(visibleOffsets); + expect(visibleIndices).toEqual( + Array.from({ length: endIndexExclusive - startIndex }, (_, index) => startIndex + index) + ); + expect(peak).toBe(4); + expect(started).toBe(visibleOffsets.length); + client.clear(); + }); + + it('shares the four-request limit between visible pages and the starred strip', async () => { + const client = createQueryClient(); + const pending: Array<{ resolve: () => void }> = []; + let active = 0; + let peak = 0; + let started = 0; + backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => { + started += 1; + active += 1; + peak = Math.max(peak, active); + return new Promise((resolve) => { + pending.push({ + resolve: () => { + active -= 1; + resolve(createPage(offset)); + }, + }); + }); + }); + + const pageRequests = Array.from({ length: 4 }, (_, index) => + client.fetchQuery(galleryItemsPageOptions(filter, index * GALLERY_PAGE_SIZE)) + ); + await vi.waitFor(() => expect(started).toBe(4)); + const stripRequest = client.fetchQuery(galleryStarredStripOptions(filter)); + await Promise.resolve(); + expect(started).toBe(4); + + pending.shift()?.resolve(); + await vi.waitFor(() => expect(started).toBe(5)); + while (pending.length) { + pending.shift()?.resolve(); + } + await Promise.all([...pageRequests, stripRequest]); + + expect(peak).toBe(4); + expect(started).toBe(5); + client.clear(); + }); + + it('removes aborted queued pages and propagates cancellation to running work', async () => { + const client = createQueryClient(); + const resolvers: Array<() => void> = []; + const signals: AbortSignal[] = []; + + backend.listGalleryItems.mockImplementation(({ offset, signal }: { offset: number; signal: AbortSignal }) => { + signals.push(signal); + + return new Promise((resolve) => { + resolvers.push(() => resolve(createPage(offset))); + }); + }); + + const options = Array.from({ length: 5 }, (_, index) => galleryItemsPageOptions(filter, index * GALLERY_PAGE_SIZE)); + const results = options.map((query) => client.fetchQuery(query).catch((error: unknown) => error)); + + await vi.waitFor(() => expect(signals).toHaveLength(4)); + await client.cancelQueries({ exact: true, queryKey: options[0].queryKey }); + await client.cancelQueries({ exact: true, queryKey: options[4].queryKey }); + + expect(signals[0]?.aborted).toBe(true); + expect(signals).toHaveLength(4); + + for (const resolve of resolvers) { + resolve(); + } + await Promise.all(results); + + expect(backend.listGalleryItems).toHaveBeenCalledTimes(4); + client.clear(); + }); + + it('aborts queued and running pages on account epoch rotation and fences late results', async () => { + const client = createQueryClient(); + const pending: Array<{ offset: number; signal: AbortSignal; resolve: (page: GalleryItemsPage) => void }> = []; + + backend.listGalleryItems.mockImplementation( + ({ offset, signal }: { offset: number; signal: AbortSignal }) => + new Promise((resolve) => { + pending.push({ offset, signal, resolve }); + }) + ); + + const oldOptions = Array.from({ length: 5 }, (_, index) => + galleryItemsPageOptions(filter, index * GALLERY_PAGE_SIZE) + ); + const oldResults = oldOptions.map((options) => client.fetchQuery(options).catch((error: unknown) => error)); + + await vi.waitFor(() => expect(pending).toHaveLength(4)); + accountLifecycle.activate('gallery-page-lifecycle-test'); + expect(pending.every((request) => request.signal.aborted)).toBe(true); + + const freshOptions = galleryItemsPageOptions(filter, 0); + expect(freshOptions.queryKey).not.toEqual(oldOptions[0]?.queryKey); + const freshResult = client.fetchQuery(freshOptions); + + pending.forEach((request) => request.resolve(createPage(request.offset))); + await Promise.all(oldResults); + + await vi.waitFor(() => expect(pending).toHaveLength(5)); + expect(pending[4]?.offset).toBe(0); + pending[4]?.resolve(createPage(0)); + + const freshPage = await freshResult; + + expect(freshPage.offset).toBe(0); + expect(freshPage.itemIndices).toHaveLength(GALLERY_PAGE_SIZE); + expect(freshPage.itemIndices?.[0]).toBe(0); + await expect(oldResults[0]).resolves.toBeInstanceOf(AccountScopeExpiredError); + expect(client.getQueryData(oldOptions[0]?.queryKey ?? [])).toBeUndefined(); + expect(client.getQueryData(freshOptions.queryKey)).toEqual(freshPage); + client.clear(); + }); + + it('refetches an LRU-pruned page directly at its requested offset', async () => { + const client = createQueryClient(); + + await Promise.all(Array.from({ length: 12 }, (_, index) => fetchPage(client, index))); + expect(pageQueries(client).some((query) => query.queryKey[6] === 0)).toBe(false); + + backend.listGalleryItems.mockClear(); + await fetchPage(client, 0); + + expect(backend.listGalleryItems).toHaveBeenCalledTimes(1); + expect(backend.listGalleryItems).toHaveBeenCalledWith(expect.objectContaining({ offset: 0 })); + client.clear(); + }); + + it('shares one page fetch among three consumer-equivalent observers', async () => { + const client = createQueryClient(); + let resolve!: (page: GalleryItemsPage) => void; + backend.listGalleryItems.mockImplementation( + () => + new Promise((complete) => { + resolve = complete; + }) + ); + + const equivalentFilters: GalleryItemsFilter[] = [filter, { ...filter, searchTerm: ' ' }, { ...filter }]; + const observers = equivalentFilters.map( + (equivalentFilter) => new QueryObserver(client, galleryItemsPageOptions(equivalentFilter, 0)) + ); + const unsubscribes = observers.map((observer) => observer.subscribe(() => undefined)); + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + resolve(createPage(0)); + + await vi.waitFor(() => { + expect(observers.every((observer) => observer.getCurrentResult().data?.offset === 0)).toBe(true); + }); + + expect(observers[1]?.getCurrentQuery()).toBe(observers[0]?.getCurrentQuery()); + expect(observers[2]?.getCurrentQuery()).toBe(observers[0]?.getCurrentQuery()); + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + unsubscribes.forEach((unsubscribe) => unsubscribe()); + observers.forEach((observer) => observer.destroy()); + client.clear(); + }); + + it('keeps an imperative page read alive after its final UI observer leaves', async () => { + const client = createQueryClient(); + let resolve!: (page: GalleryItemsPage) => void; + let requestSignal: AbortSignal | undefined; + backend.listGalleryItems.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((complete) => { + resolve = complete; + requestSignal = signal; + }) + ); + + const options = galleryItemsPageOptions(filter, 0); + const observer = new QueryObserver(client, { ...options, enabled: false }); + const unsubscribe = observer.subscribe(() => undefined); + const imperativeRead = fetchGalleryItemsPage(client, filter, 0); + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + unsubscribe(); + + expect(requestSignal?.aborted).toBe(false); + resolve(createPage(0)); + await expect(imperativeRead).resolves.toMatchObject({ offset: 0 }); + + observer.destroy(); + client.clear(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queries.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/queries.test.ts index 432af339312..33db5fa434d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queries.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queries.test.ts @@ -1,8 +1,8 @@ import type { GalleryItem, GalleryItemsPage } from '@features/gallery/core/items'; import type { GallerySemanticReference } from '@features/gallery/core/semanticImageQuery'; -import { accountLifecycle } from '@platform/state/accountLifecycle'; -import { InfiniteQueryObserver, QueryClient, type InfiniteData } from '@tanstack/react-query'; +import { AccountScopeExpiredError, accountLifecycle } from '@platform/state/accountLifecycle'; +import { InfiniteQueryObserver, QueryClient, QueryObserver, type InfiniteData } from '@tanstack/react-query'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const backend = vi.hoisted(() => ({ @@ -26,6 +26,8 @@ import { GALLERY_STARRED_STRIP_LIMIT, galleryBoardsOptions, galleryItemNamesOptions, + galleryItemsPageOptions, + galleryItemsTotalOptions, galleryItemsInfiniteOptions, galleryStarredStripOptions, getGalleryItemListQueries, @@ -122,6 +124,204 @@ describe('Gallery item query read model', () => { expect(backend.listGalleryDateBoards).toHaveBeenCalledOnce(); }); + it('keys sparse pages by canonical account, listing, and absolute offset identity', () => { + const page = galleryItemsPageOptions(baseFilter, 60); + const samePage = galleryItemsPageOptions({ ...baseFilter, searchTerm: ' portrait ' }, 119); + + expect(samePage.queryKey).toEqual(page.queryKey); + expect(galleryItemsPageOptions(baseFilter, 120).queryKey).not.toEqual(page.queryKey); + for (const changedFilter of [ + { boardId: 'board-2' }, + { galleryView: 'assets' as const }, + { searchTerm: 'landscape' }, + { createdFrom: '2026-07-01' }, + { orderDir: 'ASC' as const }, + { starred: true }, + ]) { + expect(galleryItemsPageOptions({ ...baseFilter, ...changedFilter }, 60).queryKey).not.toEqual(page.queryKey); + } + + const semantic = galleryItemsPageOptions( + { ...baseFilter, semanticQuery: { fileId: 'external-1', kind: 'file', label: 'portrait.png' } }, + 60 + ); + expect( + galleryItemsPageOptions( + { ...baseFilter, semanticQuery: { fileId: 'external-1', kind: 'file', label: 'renamed.png' } }, + 60 + ).queryKey + ).toEqual(semantic.queryKey); + expect( + galleryItemsPageOptions( + { ...baseFilter, semanticQuery: { fileId: 'external-2', kind: 'file', label: 'portrait.png' } }, + 60 + ).queryKey + ).not.toEqual(semantic.queryKey); + + accountLifecycle.activate('gallery-query-test'); + + expect(galleryItemsPageOptions(baseFilter, 60).queryKey).not.toEqual(page.queryKey); + }); + + it('fetches one normalized sparse page through the shared range reader', async () => { + const queryClient = createQueryClient(); + backend.listGalleryItems.mockImplementation(({ limit, offset }: { limit: number; offset: number }) => + Promise.resolve(createPage({ count: limit, offset, total: 240 })) + ); + const options = galleryItemsPageOptions(baseFilter, 119); + + const page = await queryClient.fetchQuery(options); + + expect(backend.listGalleryItems).toHaveBeenCalledWith(expect.objectContaining({ limit: 60, offset: 60 })); + expect(page).toMatchObject({ itemIndices: Array.from({ length: 60 }, (_, index) => 60 + index), offset: 60 }); + expect(getGalleryItemListQueries(queryClient).map((query) => query.queryKey)).toEqual([options.queryKey]); + }); + + it('shares a count-only read on the account and listing key without fetching item rows', async () => { + const queryClient = createQueryClient(); + let total = 62; + backend.listGalleryItems.mockImplementation(() => Promise.resolve({ items: [], total })); + const options = galleryItemsTotalOptions(baseFilter); + + await expect( + Promise.all([ + queryClient.fetchQuery(options), + queryClient.fetchQuery(galleryItemsTotalOptions({ ...baseFilter, searchTerm: ' portrait ' })), + ]) + ).resolves.toEqual([62, 62]); + + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + expect(backend.listGalleryItems).toHaveBeenCalledWith(expect.objectContaining({ limit: 0, offset: 0 })); + expect(options.queryKey).toEqual(galleryItemsTotalOptions(baseFilter).queryKey); + expect(options.queryKey).not.toEqual(galleryItemsTotalOptions({ ...baseFilter, boardId: 'board-2' }).queryKey); + total = 63; + await invalidateGalleryItems(queryClient); + await expect(queryClient.fetchQuery(options)).resolves.toBe(63); + expect(backend.listGalleryItems).toHaveBeenCalledTimes(2); + accountLifecycle.activate('gallery-query-test-count-transition'); + expect(galleryItemsTotalOptions(baseFilter).queryKey).not.toEqual(options.queryKey); + }); + + it('discovers date-board totals through its shared name metadata without hydrating rows', async () => { + const queryClient = createQueryClient(); + const dateFilter = { ...baseFilter, boardId: 'by_date:2026-07-18' }; + backend.listGalleryDateBoardItemNames.mockResolvedValue({ + items: [{ kind: 'image', name: 'date-image' }], + total: 1, + }); + backend.hydrateGalleryDateBoardItemPage.mockResolvedValue({ items: [], offset: 0, total: 1 }); + + await expect(queryClient.fetchQuery(galleryItemsTotalOptions(dateFilter))).resolves.toBe(1); + expect(backend.listGalleryDateBoardItemNames).toHaveBeenCalledOnce(); + expect(backend.hydrateGalleryDateBoardItemPage).toHaveBeenCalledWith( + expect.objectContaining({ limit: 0, offset: 0, total: 1 }) + ); + }); + + it('keeps short final API pages aligned to their requested absolute offset', async () => { + const queryClient = createQueryClient(); + backend.listGalleryItems.mockResolvedValue(createPage({ count: 2, offset: 60, total: 62 })); + + const page = await queryClient.fetchQuery(galleryItemsPageOptions(baseFilter, 60)); + + expect(page).toMatchObject({ itemIndices: [60, 61], offset: 60, total: 62 }); + }); + + it('truncates an overlong result with its absolute indices still aligned', async () => { + const queryClient = createQueryClient(); + backend.listGalleryItems.mockResolvedValue(createPage({ count: GALLERY_PAGE_SIZE + 2, offset: 60, total: 240 })); + + const page = await queryClient.fetchQuery(galleryItemsPageOptions(baseFilter, 60)); + + expect(page.items).toHaveLength(GALLERY_PAGE_SIZE); + expect(page.itemIndices).toEqual(Array.from({ length: GALLERY_PAGE_SIZE }, (_, index) => 60 + index)); + }); + + it('deduplicates concurrent requests for the same sparse page', async () => { + const queryClient = createQueryClient(); + let resolvePage: ((page: GalleryItemsPage) => void) | undefined; + backend.listGalleryItems.mockImplementation( + () => + new Promise((resolve) => { + resolvePage = resolve; + }) + ); + const options = galleryItemsPageOptions(baseFilter, 60); + const firstRequest = queryClient.fetchQuery(options); + const secondRequest = queryClient.fetchQuery(galleryItemsPageOptions(baseFilter, 60)); + const expectedPage = { + ...createPage({ offset: 60, total: 240 }), + itemIndices: Array.from({ length: GALLERY_PAGE_SIZE }, (_, index) => 60 + index), + offset: 60, + }; + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + resolvePage?.(createPage({ offset: 60, total: 240 })); + + await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([expectedPage, expectedPage]); + expect(backend.listGalleryItems).toHaveBeenCalledOnce(); + }); + + it('rejects a sparse page result after its captured account lifetime expires', async () => { + const queryClient = createQueryClient(); + let requestSignal: AbortSignal | undefined; + let resolvePage: ((page: GalleryItemsPage) => void) | undefined; + backend.listGalleryItems.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve) => { + requestSignal = signal; + resolvePage = resolve; + }) + ); + const options = galleryItemsPageOptions(baseFilter, 60); + const request = queryClient.fetchQuery(options); + + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + accountLifecycle.activate('next-gallery-query-test'); + resolvePage?.(createPage({ offset: 60, total: 240 })); + + await expect(request).rejects.toBeInstanceOf(AccountScopeExpiredError); + expect(requestSignal?.aborted).toBe(true); + expect(queryClient.getQueryData(options.queryKey)).toBeUndefined(); + }); + + it('keeps date-board sparse pages on the existing shared hydration path', async () => { + const queryClient = createQueryClient(); + backend.listGalleryDateBoardItemNames.mockResolvedValue({ + items: Array.from({ length: 180 }, (_, index) => ({ kind: 'image' as const, name: `date-${index}` })), + total: 180, + }); + backend.hydrateGalleryDateBoardItemPage.mockResolvedValue(createPage({ offset: 60, total: 180 })); + + await queryClient.fetchQuery(galleryItemsPageOptions({ ...baseFilter, boardId: 'by_date:2026-07-18' }, 119)); + + expect(backend.listGalleryDateBoardItemNames).toHaveBeenCalledOnce(); + expect(backend.hydrateGalleryDateBoardItemPage).toHaveBeenCalledWith( + expect.objectContaining({ limit: 60, offset: 60 }) + ); + expect(backend.listGalleryItems).not.toHaveBeenCalled(); + }); + + it('refetches active sparse pages during Gallery list invalidation', async () => { + const queryClient = createQueryClient(); + backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => + Promise.resolve(createPage({ offset, prefix: `page-${backend.listGalleryItems.mock.calls.length}`, total: 240 })) + ); + const observer = new QueryObserver(queryClient, galleryItemsPageOptions(baseFilter, 60)); + const unsubscribe = observer.subscribe(() => undefined); + + try { + await vi.waitFor(() => expect(backend.listGalleryItems).toHaveBeenCalledOnce()); + await invalidateGalleryItems(queryClient); + + expect(backend.listGalleryItems).toHaveBeenCalledTimes(2); + expect(observer.getCurrentResult().data?.items[0]?.name).toBe('page-2-60'); + } finally { + unsubscribe(); + observer.destroy(); + } + }); + it('loads ten fixed pages into one bounded logical query', async () => { const queryClient = createQueryClient(); backend.listGalleryItems.mockImplementation(({ offset }: { offset: number }) => @@ -199,6 +399,26 @@ describe('Gallery item query read model', () => { expect(galleryItemsInfiniteOptions(baseFilter).queryKey).not.toEqual(options.queryKey); }); + it('preserves sparse absolute indices from semantic hydration', async () => { + const queryClient = createQueryClient(); + const semanticFilter: GalleryItemsFilter = { + ...baseFilter, + semanticQuery: { imageName: 'reference.png', kind: 'image' }, + }; + const itemIndices = [60, 62]; + const items = [createItem(60, 'semantic'), createItem(62, 'semantic')]; + + backend.listSemanticGalleryItemNames.mockResolvedValue({ + items: Array.from({ length: 63 }, (_, index) => ({ kind: 'image' as const, name: `rank-${index}` })), + total: 63, + }); + backend.hydrateGalleryDateBoardItemPage.mockResolvedValue({ items, itemIndices, offset: 60, total: 63 }); + + const page = await queryClient.fetchQuery(galleryItemsPageOptions(semanticFilter, 60)); + + expect(page).toEqual({ items, itemIndices, offset: 60, total: 63 }); + }); + it('keeps semantic filters in the key while page params stay inside one cache entry', async () => { const baseKey = galleryItemsInfiniteOptions(baseFilter).queryKey; @@ -348,7 +568,11 @@ describe('Gallery item query read model', () => { backend.listGalleryDateBoardItemNames.mockResolvedValue({ items: refs, total: refs.length }); backend.hydrateGalleryDateBoardItemPage.mockImplementation( ({ limit, offset, total }: { limit: number; offset: number; total: number }) => - Promise.resolve(createPage({ count: limit, offset, prefix: 'date', total })) + Promise.resolve({ + ...createPage({ count: limit, offset, prefix: 'date', total }), + itemIndices: Array.from({ length: limit }, (_, index) => offset + index), + offset, + }) ); const options = galleryItemsInfiniteOptions({ ...baseFilter, boardId: 'by_date:2026-07-18' }); const observer = new InfiniteQueryObserver(queryClient, options); @@ -362,7 +586,12 @@ describe('Gallery item query read model', () => { expect(backend.hydrateGalleryDateBoardItemPage.mock.calls.map(([request]) => request.offset)).toEqual([ 0, 60, 120, ]); - expect(flattenGalleryItemsData(observer.getCurrentResult().data)).toHaveLength(180); + const data = observer.getCurrentResult().data; + + expect(flattenGalleryItemsData(data)).toHaveLength(180); + expect(data?.pages.map((page) => Object.keys(page).sort())).toEqual( + Array.from({ length: 3 }, () => ['items', 'total']) + ); expect(backend.listGalleryItems).not.toHaveBeenCalled(); backend.listGalleryDateBoardItemNames.mockResolvedValueOnce({ @@ -553,9 +782,17 @@ describe('galleryStarredStripOptions', () => { const queryClient = createQueryClient(); const refs = [{ kind: 'image' as const, name: 'starred-0' }]; backend.listGalleryDateBoardItemNames.mockResolvedValue({ items: refs, total: 1 }); - backend.hydrateGalleryDateBoardItemPage.mockResolvedValue(createPage({ count: 1, offset: 0, total: 1 })); + backend.hydrateGalleryDateBoardItemPage.mockResolvedValue({ + ...createPage({ count: 1, offset: 0, total: 1 }), + itemIndices: [0], + offset: 0, + }); + + const page = await queryClient.fetchQuery( + galleryStarredStripOptions({ ...baseFilter, boardId: 'by_date:2026-07-18' }) + ); - await queryClient.fetchQuery(galleryStarredStripOptions({ ...baseFilter, boardId: 'by_date:2026-07-18' })); + expect(page).toEqual({ items: [createItem(0)], total: 1 }); expect(backend.listGalleryDateBoardItemNames.mock.calls[0]?.[0]).toMatchObject({ boardId: 'by_date:2026-07-18', diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts index aea400d7b37..cc69a85f427 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts @@ -1,4 +1,4 @@ -import type { GalleryItem, GalleryItemsPage } from '@features/gallery/core/items'; +import type { GalleryItem, GalleryItemRef, GalleryItemsPage } from '@features/gallery/core/items'; import type { GallerySemanticQuery, GallerySemanticReference } from '@features/gallery/core/semanticImageQuery'; import type { GallerySettings } from '@features/gallery/core/settings'; import type { GalleryBoardOrderBy, GalleryOrderDir, GalleryView } from '@features/gallery/core/types'; @@ -16,15 +16,21 @@ import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/ import { hashKey, infiniteQueryOptions, + isCancelledError, queryOptions, type InfiniteData, + type Query, type QueryClient, type QueryKey, + type QueryObserverOptions, + QueryObserver, } from '@tanstack/react-query'; import { + type GalleryItemLocation, type GalleryItemNames, fetchImageIndexAvailability, + getGalleryItemLocation, hydrateGalleryDateBoardItemPage, isDateBoardId, listGalleryBoards, @@ -36,6 +42,7 @@ import { } from './backend'; export { GALLERY_MAX_INFINITE_PAGES, GALLERY_MAX_ROWS, GALLERY_PAGE_SIZE, GALLERY_STARRED_STRIP_LIMIT }; +export { isDateBoardId }; export interface GalleryBoardsQuery { includeArchived?: boolean; @@ -103,12 +110,15 @@ type GalleryItemsInfiniteQueryKey = readonly [ type GalleryItemsAnchorQueryKey = readonly [...GalleryItemsInfiniteQueryKey, 'anchor' | 'infinite', number]; +export type GalleryItemsPageQueryKey = readonly [...GalleryItemsInfiniteQueryKey, 'page', number]; + /** The bounded starred strip: one `GalleryItemsPage`, not an infinite window. */ type GalleryItemsStripQueryKey = readonly [...GalleryItemsInfiniteQueryKey, 'strip']; export type GalleryItemsListQueryKey = | GalleryItemsAnchorQueryKey | GalleryItemsInfiniteQueryKey + | GalleryItemsPageQueryKey | GalleryItemsStripQueryKey; const canonicalizeBoardsQuery = (query: GalleryBoardsQuery): CanonicalGalleryBoardsQuery => ({ @@ -180,6 +190,10 @@ export const galleryKeys = { window: GalleryItemsWindow = { kind: 'infinite' } ): GalleryItemsListQueryKey => [...galleryKeys.itemListsForAccount(owner), filter, ...getWindowKey(window)] as GalleryItemsListQueryKey, + itemPage: (owner: AccountScope, filter: CanonicalGalleryItemsFilter, offset: number): GalleryItemsPageQueryKey => + [...galleryKeys.itemListsForAccount(owner), filter, 'page', normalizePageOffset(offset)] as const, + itemTotal: (owner: AccountScope, filter: CanonicalGalleryItemsFilter) => + [...galleryKeys.itemListsForAccount(owner), filter, 'total'] as const, starredStrip: (owner: AccountScope, filter: CanonicalGalleryItemsFilter): GalleryItemsStripQueryKey => [...galleryKeys.itemListsForAccount(owner), filter, 'strip'] as const, itemNamesRoot: () => [...galleryKeys.itemsRoot(), 'names'] as const, @@ -218,6 +232,585 @@ export const galleryItemNamesOptions = (inputFilter: GalleryItemsFilter) => { return galleryItemNamesOptionsForOwner(owner, canonicalizeGalleryItemsFilter(inputFilter)); }; +const MAX_INACTIVE_PAGES_PER_LISTING = 10; +const MAX_CONCURRENT_PAGE_FETCHES = 4; + +interface PendingPageFetch { + signal: AbortSignal; + run: () => Promise; + resolve: (result: T) => void; + reject: (error: unknown) => void; + onAbort: () => void; + started: boolean; +} + +interface GalleryPageLifecycle { + lastUse: WeakMap; + nextUse: number; + queue: PendingPageFetch[]; + activeFetches: number; + unsubscribe: () => void; +} + +const lifecycles = new WeakMap(); + +/** The account-and-filter prefix that every cached window and page of one item listing shares. */ +export const getGalleryItemListingKey = (queryKey: QueryKey): QueryKey => queryKey.slice(0, 5); + +const getSparsePageIdentity = (queryKey: QueryKey): { listingKey: QueryKey; listingHash: string } | null => { + if ( + queryKey.length !== 7 || + queryKey[0] !== 'gallery' || + queryKey[1] !== 'items' || + queryKey[2] !== 'list' || + !queryKey[3] || + typeof queryKey[3] !== 'object' || + !queryKey[4] || + typeof queryKey[4] !== 'object' || + queryKey[5] !== 'page' || + typeof queryKey[6] !== 'number' + ) { + return null; + } + + const listingKey = getGalleryItemListingKey(queryKey); + + return { listingKey, listingHash: hashKey(listingKey) }; +}; + +const ensureLifecycle = (client: QueryClient): GalleryPageLifecycle => { + const existing = lifecycles.get(client); + + if (existing) { + return existing; + } + + const lifecycle: GalleryPageLifecycle = { + lastUse: new WeakMap(), + nextUse: 0, + queue: [], + activeFetches: 0, + unsubscribe: () => undefined, + }; + + const touch = (query: Query) => lifecycle.lastUse.set(query, ++lifecycle.nextUse); + + const pruneListing = (listingKey: QueryKey) => { + const candidates = client + .getQueryCache() + .findAll({ queryKey: listingKey }) + .filter((query) => { + const identity = getSparsePageIdentity(query.queryKey); + + if (!identity || identity.listingHash !== hashKey(listingKey)) { + return false; + } + + return ( + query.state.fetchStatus === 'idle' && + (query.state.data !== undefined || query.state.status === 'error') && + query.getObserversCount() === 0 + ); + }); + + if (candidates.length <= MAX_INACTIVE_PAGES_PER_LISTING) { + return; + } + + candidates.sort((left, right) => { + const leftUse = lifecycle.lastUse.get(left) ?? left.state.dataUpdatedAt; + const rightUse = lifecycle.lastUse.get(right) ?? right.state.dataUpdatedAt; + + return leftUse - rightUse || Number(left.queryKey[6]) - Number(right.queryKey[6]); + }); + + for (const query of candidates.slice(0, candidates.length - MAX_INACTIVE_PAGES_PER_LISTING)) { + client.removeQueries({ exact: true, queryKey: query.queryKey }); + } + }; + + const seedExistingPages = () => { + const existingPages = client + .getQueryCache() + .findAll({ queryKey: ['gallery', 'items', 'list'] }) + .filter((query) => getSparsePageIdentity(query.queryKey)) + .sort((left, right) => left.state.dataUpdatedAt - right.state.dataUpdatedAt); + + for (const query of existingPages) { + if (!lifecycle.lastUse.has(query)) { + touch(query); + } + } + }; + + seedExistingPages(); + lifecycle.unsubscribe = client.getQueryCache().subscribe((event) => { + if (event.type === 'removed') { + return; + } + + const identity = getSparsePageIdentity(event.query.queryKey); + + if (!identity) { + return; + } + + // A failed page waits for its own Retry only while it stays in view. Once nothing observes it, forget the failure + // so returning to the page reads it again, whether its last observer left or it failed with none left to see it. + // (A read its last observer leaves mid-flight is cancelled back to its prior state before `observerRemoved`.) + if ( + (event.type === 'observerRemoved' || (event.type === 'updated' && event.action.type === 'error')) && + event.query.state.status === 'error' && + event.query.state.fetchStatus === 'idle' && + event.query.getObserversCount() === 0 + ) { + client.removeQueries({ exact: true, queryKey: event.query.queryKey }); + return; + } + + if ( + event.type === 'added' || + event.type === 'observerAdded' || + event.type === 'observerRemoved' || + (event.type === 'updated' && event.action.type === 'success') + ) { + touch(event.query); + } + + pruneListing(identity.listingKey); + }); + + lifecycles.set(client, lifecycle); + + return lifecycle; +}; + +const schedulePageFetch = ( + lifecycle: GalleryPageLifecycle, + signal: AbortSignal, + run: () => Promise +): Promise => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + return; + } + + const task: PendingPageFetch = { + signal, + run, + resolve, + reject, + started: false, + onAbort: () => { + if (task.started) { + return; + } + + const index = lifecycle.queue.indexOf(task as PendingPageFetch); + + if (index !== -1) { + lifecycle.queue.splice(index, 1); + } + + signal.removeEventListener('abort', task.onAbort); + reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + }, + }; + + signal.addEventListener('abort', task.onAbort, { once: true }); + lifecycle.queue.push(task as PendingPageFetch); + + const pump = () => { + while (lifecycle.activeFetches < MAX_CONCURRENT_PAGE_FETCHES && lifecycle.queue.length > 0) { + const next = lifecycle.queue.shift(); + + if (!next) { + return; + } + + if (next.signal.aborted) { + next.onAbort(); + continue; + } + + next.started = true; + lifecycle.activeFetches += 1; + void next + .run() + .then( + (result) => { + next.signal.removeEventListener('abort', next.onAbort); + if (next.signal.aborted) { + next.reject(next.signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + } else { + next.resolve(result); + } + }, + (error: unknown) => { + next.signal.removeEventListener('abort', next.onAbort); + next.reject(error); + } + ) + .finally(() => { + lifecycle.activeFetches -= 1; + pump(); + }); + } + }; + + pump(); + }); + +/** Query deduplicates same-key calls before they enter this per-client bounded scheduler. */ +const fetchGalleryPageWithLifecycle = ( + client: QueryClient, + signal: AbortSignal, + run: () => Promise +): Promise => schedulePageFetch(ensureLifecycle(client), signal, run); + +/** One Query-owned page at an absolute offset. Shared by every consumer of the same account/listing/page. */ +export const galleryItemsPageOptions = (inputFilter: GalleryItemsFilter, offset: number) => { + const owner = captureAccountScope(); + const filter = canonicalizeGalleryItemsFilter(inputFilter); + const pageOffset = normalizePageOffset(offset); + + return queryOptions({ + queryFn: ({ client, signal }) => { + const requestSignal = AbortSignal.any([signal, owner.signal]); + + return fetchGalleryPageWithLifecycle(client, requestSignal, () => + fetchGalleryItemsRange(client, owner, filter, { + limit: GALLERY_PAGE_SIZE, + offset: pageOffset, + signal: requestSignal, + includeAbsolutePositions: true, + }) + ); + }, + queryKey: galleryKeys.itemPage(owner, filter, pageOffset), + staleTime: 60_000, + }); +}; + +/** A bounded count-only read shared by every page of one account-scoped listing. */ +export const galleryItemsTotalOptions = (inputFilter: GalleryItemsFilter) => { + const owner = captureAccountScope(); + const filter = canonicalizeGalleryItemsFilter(inputFilter); + + return queryOptions({ + queryFn: async ({ client, signal }) => { + const requestSignal = AbortSignal.any([signal, owner.signal]); + const page = await fetchGalleryItemsRange(client, owner, filter, { + limit: 0, + offset: 0, + signal: requestSignal, + }); + + return page.total; + }, + queryKey: galleryKeys.itemTotal(owner, filter), + staleTime: 60_000, + }); +}; + +const galleryItemLocationKey = ( + owner: AccountScope, + filter: ReturnType, + ref: GalleryItemRef +) => ['gallery', 'item-location', getAccountKey(owner), filter, ref] as const; + +interface SharedQueryConsumerState { + count: number; + unsubscribeObserver: () => void; +} + +const sharedQueryConsumers = new WeakMap>(); + +/** Stop one caller's wait immediately; cancel the Query only after its final caller leaves. */ +const fetchSharedQuery = ( + client: QueryClient, + options: QueryObserverOptions, + signal: AbortSignal | undefined, + cancelQueryWhenUnused: boolean, + fetch: () => Promise +): Promise => { + if (signal?.aborted) { + return Promise.reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + } + + const queryHash = hashKey(options.queryKey); + const consumers = sharedQueryConsumers.get(client) ?? new Map(); + + sharedQueryConsumers.set(client, consumers); + let sharedState = consumers.get(queryHash); + + if (!sharedState) { + // The explicit Query observer keeps TanStack from aborting a signal-aware read when its last UI observer leaves + // but an imperative reveal/location caller still awaits the shared request. + const observer = new QueryObserver(client, { ...options, enabled: false }); + + sharedState = { count: 0, unsubscribeObserver: observer.subscribe(() => undefined) }; + consumers.set(queryHash, sharedState); + } + sharedState.count += 1; + + return new Promise((resolve, reject) => { + let settled = false; + const release = (cancelIfLast: boolean) => { + const state = consumers.get(queryHash); + const remainingConsumers = Math.max(0, (state?.count ?? 1) - 1); + + if (remainingConsumers === 0) { + consumers.delete(queryHash); + state?.unsubscribeObserver(); + const query = client.getQueryCache().find({ exact: true, queryKey: options.queryKey }); + + if (cancelIfLast && cancelQueryWhenUnused && (query?.getObserversCount() ?? 0) === 0) { + void client.cancelQueries({ exact: true, queryKey: options.queryKey }); + } + if (consumers.size === 0) { + sharedQueryConsumers.delete(client); + } + } else { + state!.count = remainingConsumers; + } + }; + const onAbort = () => { + if (settled) { + return; + } + + settled = true; + signal?.removeEventListener('abort', onAbort); + release(true); + reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + + settled = true; + signal?.removeEventListener('abort', onAbort); + release(false); + complete(); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + + let request: Promise; + + try { + request = fetch(); + } catch (error: unknown) { + settle(() => reject(error)); + return; + } + + void request.then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)) + ); + }); +}; + +/** Fetch one shared page while tracking every imperative consumer that cannot be seen by Query observers. */ +export const fetchGalleryItemsPage = ( + queryClient: QueryClient, + inputFilter: GalleryItemsFilter, + offset: number, + { signal, staleTime }: { signal?: AbortSignal; staleTime?: number } = {} +): Promise => { + const pageOptions = galleryItemsPageOptions(inputFilter, offset); + + return fetchSharedQuery(queryClient, pageOptions, signal, true, () => + queryClient.fetchQuery(staleTime === undefined ? pageOptions : { ...pageOptions, staleTime }) + ); +}; + +const galleryItemLocationOptionsForOwner = ( + owner: AccountScope, + inputFilter: GalleryItemsFilter, + ref: GalleryItemRef +) => { + const filter = canonicalizeGalleryItemsFilter(inputFilter); + + if (filter.semantic) { + throw new TypeError('Semantic gallery results do not have an ordinary listing location.'); + } + + return queryOptions({ + queryFn: async ({ signal }) => { + const requestSignal = AbortSignal.any([signal, owner.signal]); + const location = await getGalleryItemLocation({ ...filter, ...ref, signal: requestSignal }); + + assertAccountScopeCurrent(owner); + requestSignal.throwIfAborted(); + + return location; + }, + queryKey: galleryItemLocationKey(owner, filter, ref), + retry: false, + staleTime: 0, + }); +}; + +/** Account-fenced location for one item in an ordinary, fully filtered gallery listing. */ +export const galleryItemLocationOptions = (inputFilter: GalleryItemsFilter, ref: GalleryItemRef) => + galleryItemLocationOptionsForOwner(captureAccountScope(), inputFilter, ref); + +export interface VerifiedGalleryItemPage { + index: number; + offset: number; + page: GalleryItemsPage; + total: number; +} + +const isOwnerKey = (key: unknown, owner: AccountScope): boolean => + Boolean( + key && + typeof key === 'object' && + 'accountId' in key && + 'epoch' in key && + key.accountId === owner.accountId && + key.epoch === owner.epoch + ); + +const pageContainsLocation = ( + page: GalleryItemsPage, + requestedOffset: number, + location: GalleryItemLocation, + ref: GalleryItemRef +): boolean => { + if ( + !Number.isSafeInteger(location.index) || + !Number.isSafeInteger(location.total) || + location.index < 0 || + location.index >= location.total || + location.total !== page.total || + (page.offset !== undefined && page.offset !== requestedOffset) || + location.kind !== ref.kind || + location.name !== ref.name + ) { + return false; + } + + const indices = page.itemIndices ?? page.items.map((_, index) => (page.offset ?? requestedOffset) + index); + const localIndex = indices.indexOf(location.index); + const item: GalleryItem | undefined = localIndex >= 0 ? page.items[localIndex] : undefined; + + return item?.kind === ref.kind && item.name === ref.name; +}; + +const isValidLocation = (location: GalleryItemLocation, ref: GalleryItemRef): boolean => + location.kind === ref.kind && + location.name === ref.name && + Number.isSafeInteger(location.index) && + Number.isSafeInteger(location.total) && + location.index >= 0 && + location.index < location.total; + +/** Gallery invalidation cancels in-flight list reads; a locator re-reads after that many before giving up. */ +const MAX_CANCELLED_LOCATOR_READS = 2; + +/** + * Resolve the target's current rank and fetch only its aligned page. A mismatch means the listing shifted between + * the rank and page reads; refresh both once, then leave failure to the caller without changing Gallery state. + */ +export const fetchVerifiedGalleryItemPage = async ( + queryClient: QueryClient, + inputFilter: GalleryItemsFilter, + ref: GalleryItemRef, + owner: AccountScope = captureAccountScope(), + signal: AbortSignal = owner.signal +): Promise => { + const filter = canonicalizeGalleryItemsFilter(inputFilter); + const requestSignal = AbortSignal.any([signal, owner.signal]); + + if (filter.semantic) { + throw new TypeError('Semantic gallery results do not have an ordinary listing location.'); + } + + const fenceError = (request: Promise): Promise => + request.catch((error: unknown) => { + // Account lifetime errors remain authoritative even though the owner signal also cancels Query work. + assertAccountScopeCurrent(owner); + requestSignal.throwIfAborted(); + throw error; + }); + + let cancelledReads = 0; + let misalignedReads = 0; + + while (misalignedReads < 2) { + assertAccountScopeCurrent(owner); + requestSignal.throwIfAborted(); + const locationOptions = galleryItemLocationOptionsForOwner(owner, filter, ref); + + const location = await fenceError( + fetchSharedQuery(queryClient, locationOptions, requestSignal, true, () => + queryClient.fetchQuery({ ...locationOptions, staleTime: 0 }) + ) + ); + + assertAccountScopeCurrent(owner); + requestSignal.throwIfAborted(); + + if (!isValidLocation(location, ref)) { + misalignedReads += 1; + continue; + } + + const offset = Math.floor(location.index / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const pageOptions = galleryItemsPageOptions(filter, offset); + + if (!isOwnerKey(pageOptions.queryKey[3], owner)) { + throw new Error('Gallery account changed before the target page could be fetched.'); + } + + // A cached page can have a fresh staleTime while an external insert/delete has shifted its offsets. Always read + // the one resolved page from the backend before treating a locator result as verified. + let page: GalleryItemsPage; + + try { + page = await fenceError( + fetchGalleryItemsPage(queryClient, filter, offset, { + signal: requestSignal, + staleTime: 0, + }) + ); + } catch (error: unknown) { + // An invalidation cancelled the read because the listing changed; its rank may have moved too, so both are + // read again. Account and navigation aborts were already rethrown by the fence. + if (isCancelledError(error) && cancelledReads < MAX_CANCELLED_LOCATOR_READS) { + cancelledReads += 1; + continue; + } + + throw error; + } + + assertAccountScopeCurrent(owner); + requestSignal.throwIfAborted(); + + if (pageContainsLocation(page, offset, location, ref)) { + return { index: location.index, offset, page, total: location.total }; + } + + misalignedReads += 1; + if (misalignedReads === 1) { + await queryClient.invalidateQueries({ exact: true, queryKey: pageOptions.queryKey, refetchType: 'none' }); + } + } + + return null; +}; + const dateBoardNamesConsumers = new WeakMap>(); /** @@ -303,7 +896,12 @@ export const fetchGalleryItemsRange = async ( client: QueryClient, owner: AccountScope, filter: CanonicalGalleryItemsFilter, - { limit, offset, signal }: { limit: number; offset: number; signal: AbortSignal } + { + limit, + offset, + signal, + includeAbsolutePositions = false, + }: { limit: number; offset: number; signal: AbortSignal; includeAbsolutePositions?: boolean } ): Promise => { let result: GalleryItemsPage; @@ -323,7 +921,24 @@ export const fetchGalleryItemsRange = async ( assertAccountScopeCurrent(owner); signal.throwIfAborted(); - return result.items.length <= limit ? result : { ...result, items: result.items.slice(0, limit) }; + if (!includeAbsolutePositions) { + return { items: result.items.slice(0, limit), total: result.total }; + } + + const itemIndices = result.itemIndices ?? result.items.map((_, index) => offset + index); + + if (itemIndices.length !== result.items.length) { + throw new TypeError('Gallery page item indices must stay aligned with its items.'); + } + + const items = result.items.slice(0, limit); + const truncatedIndices = itemIndices.slice(0, limit); + + if (truncatedIndices.some((index) => !Number.isSafeInteger(index) || index < offset || index >= offset + limit)) { + throw new RangeError('Gallery page item indices must stay within the requested range.'); + } + + return { ...result, items, itemIndices: truncatedIndices, offset }; }; /** @@ -456,12 +1071,17 @@ export const galleryStarredStripOptions = (inputFilter: GalleryItemsFilter) => { const filter: CanonicalGalleryItemsFilter = { ...canonicalizeGalleryItemsFilter(inputFilter), starred: true }; return queryOptions({ - queryFn: ({ client, signal }) => - fetchGalleryItemsRange(client, owner, filter, { - limit: GALLERY_STARRED_STRIP_LIMIT, - offset: 0, - signal: AbortSignal.any([signal, owner.signal]), - }), + queryFn: ({ client, signal }) => { + const requestSignal = AbortSignal.any([signal, owner.signal]); + + return fetchGalleryPageWithLifecycle(client, requestSignal, () => + fetchGalleryItemsRange(client, owner, filter, { + limit: GALLERY_STARRED_STRIP_LIMIT, + offset: 0, + signal: requestSignal, + }) + ); + }, queryKey: galleryKeys.starredStrip(owner, filter), staleTime: 60_000, }); @@ -469,6 +1089,9 @@ export const galleryStarredStripOptions = (inputFilter: GalleryItemsFilter) => { export const isGalleryStarredStripQueryKey = (queryKey: QueryKey): boolean => queryKey[5] === 'strip'; +export const isGallerySinglePageQueryKey = (queryKey: QueryKey): boolean => + queryKey[5] === 'page' || isGalleryStarredStripQueryKey(queryKey); + export const flattenGalleryItemsData = (data: InfiniteData | undefined): GalleryItem[] => { if (!data) { return []; diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts index 4879ac4926b..93dbaecadd0 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts @@ -96,6 +96,13 @@ const createData = (pages: GalleryItem[][]): GalleryItemsData => { const getItemsKey = (boardId: string, owner: AccountScope = captureAccountScope()) => galleryKeys.items(owner, canonicalizeGalleryItemsFilter({ boardId, galleryView: 'images', searchTerm: '' })); +const getPageKey = (boardId: string, offset = 60, owner: AccountScope = captureAccountScope()) => + galleryKeys.itemPage( + owner, + canonicalizeGalleryItemsFilter({ boardId, galleryView: 'images', searchTerm: '' }), + offset + ); + const getData = (client: QueryClient, queryKey: ReturnType): GalleryItemsData => { const data = client.getQueryData(queryKey); @@ -302,6 +309,137 @@ describe('Gallery item cache patches', () => { expect(cluster.pages.map((page) => page.total)).toEqual([2]); }); + describe('sparse page entries', () => { + it('patches star, delete, and move results and restores each page on rollback', () => { + const client = createClient(); + const target = createItem('sparse-target.png', 'board-1', false); + const deleted = createItem('sparse-deleted.png', 'board-1', false, 'video'); + const moved = createItem('sparse-moved.png', 'board-1', false); + const other = createItem('sparse-other.png', 'board-1', false); + const key = getPageKey('board-1'); + const before: GalleryItemsPage = { + items: [target, deleted, moved, other], + itemIndices: [60, 62, 64, 65], + offset: 60, + total: 12, + }; + + client.setQueryData(key, before); + + expect(getGalleryItemStarredFromCaches(client, [{ kind: 'image', name: target.name }])).toEqual( + new Map([[`image:${target.name}`, false]]) + ); + expect(getGalleryItemBoardIdsFromCaches(client, [{ kind: 'image', name: moved.name }])).toEqual( + new Map([[`image:${moved.name}`, 'board-1']]) + ); + + const rollbackStar = patchGalleryItemCaches(client, { + kind: 'star', + result: getResult([{ kind: 'image', name: target.name }]), + starred: true, + }); + expect(client.getQueryData(key)?.items[0]).toEqual({ ...target, starred: true }); + expect(client.getQueryData(key)?.itemIndices).toEqual([60, 62, 64, 65]); + expect(client.getQueryData(key)?.total).toBe(12); + rollbackStar(); + expect(client.getQueryData(key)).toEqual(before); + + const rollbackDelete = patchGalleryItemCaches(client, { + kind: 'delete', + result: getResult([{ kind: 'video', name: deleted.name }]), + }); + expect(client.getQueryData(key)).toEqual({ + items: [target, moved, other], + itemIndices: [60, 64, 65], + offset: 60, + total: 11, + }); + rollbackDelete(); + expect(client.getQueryData(key)).toEqual(before); + + const rollbackMove = patchGalleryItemCaches(client, { + boardId: 'board-2', + kind: 'move', + result: getResult([{ kind: 'image', name: moved.name }]), + }); + expect(client.getQueryData(key)).toEqual({ + items: [target, deleted, other], + itemIndices: [60, 62, 65], + offset: 60, + total: 11, + }); + rollbackMove(); + expect(client.getQueryData(key)).toEqual(before); + }); + it('decrements every cached page of a listing by the items removed from any of them', () => { + const client = createClient(); + const firstKey = getPageKey('board-1', 0); + const lastKey = getPageKey('board-1', 60); + const otherListingKey = getPageKey('board-2', 0); + const firstPage: GalleryItemsPage = { + items: [createItem('first.png')], + itemIndices: [0], + offset: 0, + total: 61, + }; + const lastPage: GalleryItemsPage = { + items: [createItem('last.png')], + itemIndices: [60], + offset: 60, + total: 61, + }; + const otherListingPage: GalleryItemsPage = { + items: [createItem('elsewhere.png', 'board-2')], + itemIndices: [0], + offset: 0, + total: 1, + }; + + client.setQueryData(firstKey, firstPage); + client.setQueryData(lastKey, lastPage); + client.setQueryData(otherListingKey, otherListingPage); + + const rollback = patchGalleryItemCaches(client, { + boardId: 'board-3', + kind: 'move', + result: getResult([{ kind: 'image', name: 'last.png' }]), + }); + + expect(client.getQueryData(firstKey)).toEqual({ ...firstPage, total: 60 }); + expect(client.getQueryData(lastKey)).toEqual({ + items: [], + itemIndices: [], + offset: 60, + total: 60, + }); + expect(client.getQueryData(otherListingKey)).toBe(otherListingPage); + + rollback(); + expect(client.getQueryData(firstKey)).toEqual(firstPage); + expect(client.getQueryData(lastKey)).toEqual(lastPage); + }); + it('re-applies a confirmed removal only to pages a mid-flight refetch restored the item to', () => { + const client = createClient(); + const firstKey = getPageKey('board-1', 0); + const lastKey = getPageKey('board-1', 60); + const firstPage: GalleryItemsPage = { items: [createItem('first.png')], itemIndices: [0], offset: 0, total: 61 }; + const lastPage: GalleryItemsPage = { items: [createItem('last.png')], itemIndices: [60], offset: 60, total: 61 }; + const removedLastPage: GalleryItemsPage = { items: [], itemIndices: [], offset: 60, total: 60 }; + const result = getResult([{ kind: 'image', name: 'last.png' }]); + + client.setQueryData(firstKey, firstPage); + client.setQueryData(lastKey, lastPage); + patchGalleryItemCaches(client, { kind: 'delete', result }); + // The last page is read again before the server applies the deletion, so it still holds the item and the total. + client.setQueryData(lastKey, lastPage); + + patchGalleryItemCaches(client, { kind: 'delete', result }, { totals: 'holder' }); + + expect(client.getQueryData(firstKey)).toEqual({ ...firstPage, total: 60 }); + expect(client.getQueryData(lastKey)).toEqual(removedLastPage); + }); + }); + describe('starred strip entries', () => { const getStripKey = (boardId: string) => galleryKeys.starredStrip( @@ -596,6 +734,10 @@ describe('Gallery window rebuild', () => { expect(data.pages[1]?.items).toHaveLength(40); expect(data.pages[0]?.items[0]?.name).toBe('fresh-0.png'); expect(data.pages.every((page) => page.total === 100)).toBe(true); + expect(data.pages.map((page) => Object.keys(page).sort())).toEqual([ + ['itemIndices', 'items', 'offset', 'total'], + ['itemIndices', 'items', 'offset', 'total'], + ]); expect(client.getQueryState(key)?.isInvalidated).toBe(false); unsubscribe(); }); @@ -655,13 +797,17 @@ describe('Gallery window rebuild', () => { it('rebuilds an observed date-board window through a fresh name list and one span hydration', async () => { const { client, key } = setUpStaleWindow(dateFilter); const unsubscribe = observeItems(client, dateFilter); + const freshItems = createPageItems('fresh', 120).filter((_, index) => index !== 61); + const freshIndices = freshItems.map((_, index) => (index < 61 ? index : index + 1)); vi.mocked(listGalleryDateBoardItemNames).mockResolvedValue({ items: createPageItems('fresh', 130).map(({ kind, name }) => ({ kind, name })), total: 130, }); vi.mocked(hydrateGalleryDateBoardItemPage).mockResolvedValue({ - items: createPageItems('fresh', 120), + items: freshItems, + itemIndices: freshIndices, + offset: 0, total: 130, }); @@ -676,7 +822,13 @@ describe('Gallery window rebuild', () => { const data = getData(client, key); expect(data.pageParams).toEqual([0, 60]); - expect(data.pages[1]?.items).toHaveLength(60); + expect(data.pages[1]?.items).toHaveLength(59); + expect(data.pages[1]).toMatchObject({ + items: expect.any(Array), + itemIndices: [60, ...Array.from({ length: 58 }, (_, index) => index + 62)], + offset: 60, + total: 130, + }); expect(client.getQueryState(key)?.isInvalidated).toBe(false); unsubscribe(); }); @@ -724,7 +876,7 @@ describe('Gallery window rebuild', () => { const data = getData(client, key); expect(data.pageParams).toEqual([0]); - expect(data.pages).toEqual([{ items: [], total: 0 }]); + expect(data.pages).toEqual([{ items: [], itemIndices: [], offset: 0, total: 0 }]); expect(client.getQueryState(key)?.isInvalidated).toBe(false); unsubscribe(); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts index 268f2193035..d8a780ee507 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts @@ -20,9 +20,10 @@ import { fetchGalleryItemsRange, GALLERY_PAGE_SIZE, galleryKeys, + getGalleryItemListingKey, getGalleryItemListQueries, getGalleryItemsFilterFromKey, - isGalleryStarredStripQueryKey, + isGallerySinglePageQueryKey, type CanonicalGalleryItemsFilter, } from './queries'; @@ -61,7 +62,7 @@ export const getRefreshedGalleryThumbnailUrl = (url: string, currentRevision: nu return refreshedUrl.toString(); }; -/** A list window's pages, or the starred strip's single page. */ +/** A list window's pages, or one sparse/strip page. */ type GalleryItemsCacheData = InfiniteData | GalleryItemsPage; interface ItemCacheRollbackEntry { @@ -94,7 +95,7 @@ const getCachedPages = (query: Query): GalleryItemsPage[] => { return data.pages; } - return isGalleryStarredStripQueryKey(query.queryKey) && isGalleryItemsPage(data) ? [data] : []; + return isGallerySinglePageQueryKey(query.queryKey) && isGalleryItemsPage(data) ? [data] : []; }; const mapPageItems = ( @@ -104,8 +105,9 @@ const mapPageItems = ( ): GalleryItemsPage => { let changed = false; const items: GalleryItem[] = []; + const itemIndices: number[] | undefined = page.itemIndices ? [] : undefined; - for (const item of page.items) { + for (const [index, item] of page.items.entries()) { const nextItem = mapItem(item); if (nextItem !== item) { @@ -113,6 +115,7 @@ const mapPageItems = ( } if (nextItem) { items.push(nextItem); + itemIndices?.push(page.itemIndices?.[index] ?? (page.offset ?? 0) + index); } } @@ -123,6 +126,7 @@ const mapPageItems = ( return { ...page, items: changed ? items : page.items, + ...(changed && itemIndices ? { itemIndices } : {}), total: Math.max(0, page.total - totalDelta), }; }; @@ -173,32 +177,16 @@ const patchItemPage = ( }); }; -const countRemovedItems = (page: GalleryItemsPage, itemKeys: ReadonlySet): number => - page.items.filter((item) => itemKeys.has(toGalleryItemKey(item))).length; - const patchItemsInfiniteData = ( data: InfiniteData, filter: CanonicalGalleryItemsFilter, patch: GalleryItemCachePatch, - itemKeys: ReadonlySet + itemKeys: ReadonlySet, + removedItemCount: number ): InfiniteData => { - const removedItemKeys = new Set(); - - if (patchRemovesItems(filter, patch)) { - for (const page of data.pages) { - for (const item of page.items) { - const key = toGalleryItemKey(item); - - if (itemKeys.has(key)) { - removedItemKeys.add(key); - } - } - } - } - let changed = false; const pages = data.pages.map((page) => { - const nextPage = patchItemPage(page, filter, patch, itemKeys, removedItemKeys.size); + const nextPage = patchItemPage(page, filter, patch, itemKeys, removedItemCount); changed ||= nextPage !== page; return nextPage; @@ -211,28 +199,115 @@ const patchItemsCacheData = ( query: Query, filter: CanonicalGalleryItemsFilter, patch: GalleryItemCachePatch, - itemKeys: ReadonlySet + itemKeys: ReadonlySet, + removedItemCount: number ): { after: GalleryItemsCacheData; before: GalleryItemsCacheData } | null => { const before = query.state.data; if (isGalleryItemsData(before)) { - return { after: patchItemsInfiniteData(before, filter, patch, itemKeys), before }; + return { after: patchItemsInfiniteData(before, filter, patch, itemKeys, removedItemCount), before }; } - // A newly starred item is left to the trailing refetch, which knows where - // it belongs chronologically in the strip. - if (isGalleryStarredStripQueryKey(query.queryKey) && isGalleryItemsPage(before)) { - return { after: patchItemPage(before, filter, patch, itemKeys, countRemovedItems(before, itemKeys)), before }; + // New items are left to the trailing refetch so their server ordering is preserved. + if (isGallerySinglePageQueryKey(query.queryKey) && isGalleryItemsPage(before)) { + return { after: patchItemPage(before, filter, patch, itemKeys, removedItemCount), before }; } return null; }; +const getListingHash = (queryKey: QueryKey): string => hashKey(getGalleryItemListingKey(queryKey)); + +/** + * Count the removed items each listing, or each cache entry, holds in its cached pages. Every cached page of a listing + * reports the same server total, so each must lose the same count: pages left disagreeing would make the sparse views + * clamp and reconcile against each other. + */ +const countRemovedItems = ( + queries: readonly Query[], + patch: GalleryItemCachePatch, + itemKeys: ReadonlySet, + getCountKey: (query: Query) => string +): Map => { + const removedKeysByCountKey = new Map>(); + + for (const query of queries) { + const filter = getGalleryItemsFilterFromKey(query.queryKey); + + if (!filter || !patchRemovesItems(filter, patch)) { + continue; + } + + const countKey = getCountKey(query); + const removedKeys = removedKeysByCountKey.get(countKey) ?? new Set(); + + removedKeysByCountKey.set(countKey, removedKeys); + for (const page of getCachedPages(query)) { + for (const item of page.items) { + const key = toGalleryItemKey(item); + + if (itemKeys.has(key)) { + removedKeys.add(key); + } + } + } + } + + return new Map([...removedKeysByCountKey].map(([countKey, keys]) => [countKey, keys.size])); +}; + +/** An optimistic star patch, or the rollback of one, for state that retains star flags outside the item caches. */ +export type GalleryItemStarPatchEvent = + | { itemKeys: ReadonlySet; kind: 'apply'; patchId: number; starred: boolean } + | { kind: 'revert'; patchId: number }; + +type GalleryItemStarPatchListener = (event: GalleryItemStarPatchEvent) => void; + +const galleryItemStarPatchListeners = new WeakMap>(); +let nextGalleryItemStarPatchId = 0; + +/** + * Observe star patches on `client`'s item caches. An unstarred item leaves starred-only listings, and an item on an + * evicted page is in no cache at all, so its flag can only be reconciled from the patch itself. + */ +export const subscribeGalleryItemStarPatches = ( + client: QueryClient, + listener: GalleryItemStarPatchListener +): (() => void) => { + let listeners = galleryItemStarPatchListeners.get(client); + + if (!listeners) { + listeners = new Set(); + galleryItemStarPatchListeners.set(client, listeners); + } + + listeners.add(listener); + + return () => listeners.delete(listener); +}; + +const emitGalleryItemStarPatch = (client: QueryClient, event: GalleryItemStarPatchEvent): void => + galleryItemStarPatchListeners.get(client)?.forEach((listener) => listener(event)); + +export interface GalleryItemCachePatchOptions { + /** + * Which cached pages a removal lowers the total of. `listing` (the default) lowers every cached page of a listing + * that holds a removed item anywhere, as a first patch must. `holder` lowers only the cache entries that still hold a + * removed item, for re-applying a patch that already lowered the rest: a page refetched since then is the only one + * still counting the item. + */ + totals?: 'holder' | 'listing'; +} + /** * Applies only backend-confirmed successes. Failed refs are intentionally * ignored, and kind-qualified keys prevent same-name images/videos colliding. */ -export const patchGalleryItemCaches = (client: QueryClient, patch: GalleryItemCachePatch): (() => void) => { +export const patchGalleryItemCaches = ( + client: QueryClient, + patch: GalleryItemCachePatch, + { totals = 'listing' }: GalleryItemCachePatchOptions = {} +): (() => void) => { const itemKeys = new Set(patch.result.succeeded.map(toGalleryItemKey)); if (itemKeys.size === 0) { @@ -245,10 +320,17 @@ export const patchGalleryItemCaches = (client: QueryClient, patch: GalleryItemCa const rollbackClusterMembers = patch.kind === 'delete' ? pruneImageClusterMembers(patch.result.succeeded.map(toGalleryItemKey)) : null; const rollbackEntries: ItemCacheRollbackEntry[] = []; + const starPatchId = patch.kind === 'star' ? ++nextGalleryItemStarPatchId : null; - for (const query of getGalleryItemListQueries(client)) { + const queries = getGalleryItemListQueries(client); + const getCountKey = + totals === 'listing' ? (query: Query) => getListingHash(query.queryKey) : (query: Query) => query.queryHash; + const removedCounts = countRemovedItems(queries, patch, itemKeys, getCountKey); + + for (const query of queries) { const filter = getGalleryItemsFilterFromKey(query.queryKey); - const patched = filter ? patchItemsCacheData(query, filter, patch, itemKeys) : null; + const removedItemCount = removedCounts.get(getCountKey(query)) ?? 0; + const patched = filter ? patchItemsCacheData(query, filter, patch, itemKeys, removedItemCount) : null; if (!patched || patched.after === patched.before) { continue; @@ -261,6 +343,10 @@ export const patchGalleryItemCaches = (client: QueryClient, patch: GalleryItemCa } } + if (patch.kind === 'star' && starPatchId !== null) { + emitGalleryItemStarPatch(client, { itemKeys, kind: 'apply', patchId: starPatchId, starred: patch.starred }); + } + return () => { rollbackClusterMembers?.(); rollBackUnclaimedEntries( @@ -268,6 +354,10 @@ export const patchGalleryItemCaches = (client: QueryClient, patch: GalleryItemCa (entry) => client.getQueryData(entry.queryKey), (entry) => client.setQueryData(entry.queryKey, entry.before) ); + + if (starPatchId !== null) { + emitGalleryItemStarPatch(client, { kind: 'revert', patchId: starPatchId }); + } }; }; @@ -438,6 +528,7 @@ const rebuildGalleryItemWindow = async (client: QueryClient, owner: AccountScope limit: span.rowCount, offset: span.offset, signal: owner.signal, + includeAbsolutePositions: true, }); } catch { return false; @@ -451,11 +542,25 @@ const rebuildGalleryItemWindow = async (client: QueryClient, owner: AccountScope return false; } - const pages: GalleryItemsPage[] = []; + const loadedRangeCount = Math.min(span.rowCount, Math.max(0, result.total - span.offset)); + const pageCount = Math.max(1, Math.ceil(loadedRangeCount / GALLERY_PAGE_SIZE)); + const pages: GalleryItemsPage[] = Array.from({ length: pageCount }, (_, pageIndex) => { + const pageOffset = span.offset + pageIndex * GALLERY_PAGE_SIZE; + const pageEnd = pageOffset + GALLERY_PAGE_SIZE; + const items: GalleryItem[] = []; + const itemIndices: number[] | undefined = result.itemIndices ? [] : undefined; - for (let index = 0; index < result.items.length; index += GALLERY_PAGE_SIZE) { - pages.push({ items: result.items.slice(index, index + GALLERY_PAGE_SIZE), total: result.total }); - } + result.items.forEach((item, index) => { + const itemIndex = result.itemIndices?.[index] ?? (result.offset ?? span.offset) + index; + + if (itemIndex >= pageOffset && itemIndex < pageEnd) { + items.push(item); + itemIndices?.push(itemIndex); + } + }); + + return { items, ...(itemIndices ? { itemIndices } : {}), offset: pageOffset, total: result.total }; + }); // TanStack never stores zero pages; an emptied span keeps one empty page. if (pages.length === 0) { diff --git a/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts index f2fcc658a4a..f2ddb9f8a71 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/semanticSearch.test.ts @@ -181,9 +181,11 @@ describe('semantic page hydration', () => { mocks.apiFetchJson .mockResolvedValueOnce({ results: [ + { image_name: 'outside-before.png', score: 1 }, { image_name: 'first.png', score: 0.9 }, - { image_name: 'second.png', score: 0.8 }, - { image_name: 'deleted.png', score: 0.7 }, + { image_name: 'deleted.png', score: 0.8 }, + { image_name: 'second.png', score: 0.7 }, + { image_name: 'outside-after.png', score: 0.6 }, ], }) // by-names returns DTOs in arbitrary order; rank order must win, and a @@ -191,10 +193,11 @@ describe('semantic page hydration', () => { .mockResolvedValueOnce([backendImage('second.png'), backendImage('first.png')]); const names = await listSemanticGalleryItemNames({ query: { kind: 'text', query: 'boats' } }); - const page = await hydrateGalleryDateBoardItemPage({ ...names, limit: 3, offset: 0 }); + const page = await hydrateGalleryDateBoardItemPage({ ...names, limit: 3, offset: 1 }); - expect(page.total).toBe(3); + expect(page.total).toBe(5); expect(page.items.map((item) => item.name)).toEqual(['first.png', 'second.png']); + expect(page).toMatchObject({ itemIndices: [1, 3], offset: 1 }); expect(page.items.every((item) => item.kind === 'image')).toBe(true); }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/queries.ts b/invokeai/frontend/webv2/src/features/gallery/queries.ts index d699843506c..e04a64bb6d8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/queries.ts @@ -1,24 +1,34 @@ export { canonicalizeGalleryItemsFilter, + fetchGalleryItemsPage, + fetchVerifiedGalleryItemPage, flattenGalleryItemsData, GALLERY_MAX_INFINITE_PAGES, GALLERY_MAX_ROWS, GALLERY_PAGE_SIZE, galleryBoardsOptions, + galleryItemLocationOptions, galleryItemNamesOptions, + galleryItemsPageOptions, + galleryItemsTotalOptions, galleryItemsInfiniteOptions, galleryKeys, galleryStarredStripOptions, + isDateBoardId, getGalleryListingBoardsQuery, imageIndexAvailabilityOptions, } from './data/queries'; +export { abortGalleryLocatorRequests, createGalleryLocatorRequest } from './core/selection'; export type { CanonicalGalleryItemsFilter, GalleryBoardsQuery, GalleryItemsFilter, GalleryItemsListQueryKey, + GalleryItemsPageQueryKey, GalleryItemsWindow, + VerifiedGalleryItemPage, } from './data/queries'; +export type { GalleryLocatorRequest } from './core/selection'; export { getGalleryItemBoardIdsFromCaches, getGalleryItemStarredFromCaches, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx index b81d72474d2..14da4cff55f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx @@ -78,6 +78,7 @@ const createGallery = (settings: Partial = {}): GalleryStateVie items: [], page: 0, pendingPlaceholders: [], + primarySelectedItemKey: null, projectBoardId: null, revealTargetPage: null, searchTerm: '', @@ -86,6 +87,7 @@ const createGallery = (settings: Partial = {}): GalleryStateVie semanticSearchText: null, selectedItemKey: null, selectedItemKeys: [], + selectionStarredOnly: false, settings: { ...DEFAULT_GALLERY_SETTINGS, showArchivedBoards: true, showDateBoards: true, ...settings }, starredOnly: false, ...({} as Record), diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryGridFocus.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryGridFocus.browser.test.tsx index c94df29c367..6a0329dc217 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryGridFocus.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryGridFocus.browser.test.tsx @@ -153,6 +153,7 @@ const READY_LISTING: GalleryListingState = { }; const selectionOf = (item: GalleryItem | null) => ({ + primarySelectedItemKey: item ? toGalleryItemKey(item) : null, selectedItemKey: item ? toGalleryItemKey(item) : null, selectedItemKeys: item ? [toGalleryItemKey(item)] : [], }); @@ -170,6 +171,7 @@ const createGallery = (items: GalleryItem[], selected: GalleryItem | null): Gall searchTerm: '', selectedBoardId: 'board-a', ...selectionOf(selected), + selectionStarredOnly: false, semanticImageQuery: null, semanticSearchText: null, // At the default density the harness shows three columns and a few rows of 400 items. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx index a6113c50161..33816006495 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx @@ -1,5 +1,5 @@ /* oxlint-disable react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-object-as-prop */ -import type { GalleryItem, GalleryItemRef } from '@features/gallery/contracts'; +import type { GalleryItem, GalleryItemRef, GalleryItemsPage } from '@features/gallery/contracts'; import type { ImageIndexAvailability } from '@features/gallery/data/backend'; import type { GalleryItemsFilter } from '@features/gallery/data/queries'; import type { QueueProgressSession } from '@features/queue/contracts'; @@ -17,8 +17,15 @@ import { type DragStartEvent, } from '@dnd-kit/core'; import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import { toGalleryItemRef } from '@features/gallery/core/items'; import { requestGalleryItemReveal } from '@features/gallery/core/selection'; import { getGallerySettings } from '@features/gallery/core/settings'; +import { + galleryBoardsOptions, + galleryItemsPageOptions, + getGalleryListingBoardsQuery, +} from '@features/gallery/data/queries'; +import { invalidateGalleryItems, patchGalleryItemCaches } from '@features/gallery/data/queryCache'; import { GalleryUiProvider, type GalleryUiAdapter } from '@features/gallery/react'; import { GALLERY_PINNED_FOOTER_PX } from '@features/gallery/ui/galleryGridLayout'; import { isGalleryImageDragData } from '@features/gallery/utility'; @@ -40,17 +47,21 @@ import { page, userEvent } from 'vitest/browser'; import type { GalleryStateView } from './galleryStateView'; import type { GalleryActions, GalleryStarredStrip, GalleryWidgetContextValue } from './GalleryWidgetContext'; -import type { GalleryListingState } from './useGalleryData'; +import type { GalleryListingState, GallerySparseListing } from './useGalleryData'; import { mergeGalleryLoadedItems } from './galleryGridLayout'; import { GalleryImageGrid } from './GalleryImageGrid'; import { GalleryWidgetContext } from './GalleryWidgetContext'; +import { useGalleryData } from './useGalleryData'; import { EMPTY_GALLERY_STARRED_STRIP } from './useGalleryStarredStrip'; const mocks = vi.hoisted(() => ({ itemProgress: null as { percentage: number; message: string } | null, progressFrame: null as { dataUrl: string; width: number; height: number } | null, fetchNames: vi.fn(), + fetchSparsePage: vi.fn<(filter: GalleryItemsFilter, offset: number) => Promise>(), + fetchSparseTotal: vi.fn<(filter: GalleryItemsFilter) => Promise>(), + getGalleryItemByRef: vi.fn<(ref: { kind: string; name: string }, signal?: AbortSignal) => Promise>(), getItemLabel: vi.fn(), indexAvailability: { modelName: null, state: 'disabled' } as ImageIndexAvailability, measure: vi.fn(), @@ -60,25 +71,56 @@ const mocks = vi.hoisted(() => ({ count: number; estimateSize: (index: number) => number; getScrollElement: () => Element | null; + onChange?: (instance: { + getVirtualItems: () => readonly { index: number }[]; + range: { endIndex: number; startIndex: number } | null; + }) => void; overscan: number; }>, + useRealGridVirtualizer: false, + partialRowPosition: null as { ratio: number; rowIndex: number; visibleRows: number } | null, + partialRowScrollOffset: null as number | null, })); const getNamesKey = (filter: unknown) => ['test-gallery-item-names', JSON.stringify(filter)] as const; +const requestReveal = (itemKey: Parameters[0], absoluteIndex?: number) => + requestGalleryItemReveal(itemKey, accountLifecycle.capture().signal, absoluteIndex); -vi.mock('@features/gallery/data/queries', async (importOriginal) => ({ +vi.mock('@features/gallery/data/backend', async (importOriginal) => ({ ...(await importOriginal>()), - imageIndexAvailabilityOptions: () => ({ - queryFn: () => mocks.indexAvailability, - queryKey: ['test-image-index-availability'], - }), - galleryItemNamesOptions: (filter: unknown) => ({ - queryFn: () => mocks.fetchNames(filter), - queryKey: getNamesKey(filter), - staleTime: Infinity, - }), + getGalleryItemByRef: mocks.getGalleryItemByRef, })); +vi.mock('@features/gallery/data/queries', async (importOriginal) => { + const actual = await importOriginal>(); + const getPageOptions = actual.galleryItemsPageOptions as ( + filter: GalleryItemsFilter, + offset: number + ) => Record; + const getTotalOptions = actual.galleryItemsTotalOptions as (filter: GalleryItemsFilter) => Record; + + return { + ...actual, + galleryItemsPageOptions: (filter: GalleryItemsFilter, offset: number) => ({ + ...getPageOptions(filter, offset), + queryFn: () => mocks.fetchSparsePage(filter, offset), + }), + galleryItemsTotalOptions: (filter: GalleryItemsFilter) => ({ + ...getTotalOptions(filter), + queryFn: () => mocks.fetchSparseTotal(filter), + }), + imageIndexAvailabilityOptions: () => ({ + queryFn: () => mocks.indexAvailability, + queryKey: ['test-image-index-availability'], + }), + galleryItemNamesOptions: (filter: unknown) => ({ + queryFn: () => mocks.fetchNames(filter), + queryKey: getNamesKey(filter), + staleTime: Infinity, + }), + }; +}); + vi.mock('react-hook-tanstack-virtual', async (importOriginal) => { const actual = await importOriginal(); return { @@ -89,9 +131,13 @@ vi.mock('react-hook-tanstack-virtual', async (importOriginal) => { scrollMargin?: number; estimateSize: (index: number) => number; getScrollElement: () => Element | null; + onChange?: (instance: { + getVirtualItems: () => readonly { index: number }[]; + range: { endIndex: number; startIndex: number } | null; + }) => void; overscan: number; }) => { - if (options.overscan === 2) { + if (options.overscan === 2 || mocks.useRealGridVirtualizer) { return actual.useVirtualizer(options); } mocks.virtualizerOptions.push(options); @@ -99,9 +145,22 @@ vi.mock('react-hook-tanstack-virtual', async (importOriginal) => { const starts = sizes.map( (_, index) => (options.scrollMargin ?? 0) + sizes.slice(0, index).reduce((total, size) => total + size, 0) ); + const partialRow = mocks.partialRowPosition; + const rowSize = sizes[partialRow?.rowIndex ?? 0] ?? 0; + const scrollOffset = partialRow ? (starts[partialRow.rowIndex] ?? 0) + rowSize * partialRow.ratio : null; + const scrollHeight = partialRow + ? (options.getScrollElement()?.clientHeight ?? rowSize * partialRow.visibleRows) + : null; + mocks.partialRowScrollOffset = scrollOffset; return { measure: mocks.measure, + range: partialRow + ? { endIndex: partialRow.rowIndex + partialRow.visibleRows, startIndex: partialRow.rowIndex } + : null, + scrollOffset, + scrollRect: + scrollHeight === null ? null : { height: scrollHeight, width: options.getScrollElement()?.clientWidth ?? 0 }, scrollToIndex: mocks.scrollToIndex, totalSize: sizes.reduce((total, size) => total + size, 0), virtualItems: Array.from({ length: options.count }, (_, index) => ({ @@ -129,7 +188,7 @@ void i18n.use(initReactI18next).init({ resources: { en: { translation: { - common: { generating: 'Generating' }, + common: { generating: 'Generating', retry: 'Retry' }, widgets: { gallery: { inProgress: 'In progress', @@ -236,9 +295,23 @@ const createFilter = (gallery: GalleryStateView): GalleryItemsFilter => { galleryView: gallery.galleryView, orderDir: gallery.settings.imageOrderDir, searchTerm: parse.text, + ...(gallery.starredOnly ? { starred: true } : {}), }; }; +// `useGalleryData` memoizes its filter, so an unchanged scope keeps one filter object across renders. +const stableFilters = new Map(); +const getStableFilter = (gallery: GalleryStateView): GalleryItemsFilter => { + const filter = createFilter(gallery); + const identity = JSON.stringify(filter); + + if (!stableFilters.has(identity)) { + stableFilters.set(identity, filter); + } + + return stableFilters.get(identity)!; +}; + /** Infinite-mode settings at the sparsest density, so few columns fit the harness. */ const DENSE_SETTINGS = { ...getGallerySettings({}), imageDensityPercent: 0 }; @@ -257,12 +330,15 @@ const createGallery = (overrides: Partial = {}): GalleryStateV isComparisonActive: false, items, page: 0, + // A visible selection is the persisted primary; tests whose primary is off-page name it explicitly. + primarySelectedItemKey: overrides.selectedItemKey === undefined ? 'image:first.png' : overrides.selectedItemKey, revealTargetPage: null, projectBoardId: null, searchTerm: '', selectedBoardId: board.id, selectedItemKey: 'image:first.png', selectedItemKeys: ['image:first.png'], + selectionStarredOnly: false, semanticImageQuery: null, semanticSearchText: null, settings: { ...getGallerySettings({ paginationMode: 'paginated' }), imageDensityPercent: 0 }, @@ -302,7 +378,17 @@ const runtime = { return () => registeredCommands.delete(id); }, }, - hotkeys: { register: () => () => undefined }, + hotkeys: { + register: ({ commandId, defaultKeys }: { commandId: string; defaultKeys: string[] }) => { + const handler = (event: KeyboardEvent) => { + if (defaultKeys.includes(event.key.toLowerCase())) { + registeredCommands.get(commandId)?.(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, + }, }; const createActions = (): GalleryActions => @@ -331,6 +417,7 @@ const createActions = (): GalleryActions => }) as unknown as GalleryActions; type CanonicalContextTarget = { + allStarred?: boolean; itemRefs?: GalleryItemRef[]; items: GalleryItem[]; x: number; @@ -343,6 +430,7 @@ const ContextMenuProbe = ({ target }: { target: CanonicalContextTarget }) => ( ? { itemRefs: target.itemRefs ?? null, items: target.items.map(({ kind, name }) => ({ kind, name })), + ...(target.allStarred === undefined ? {} : { allStarred: target.allStarred }), } : null )} @@ -354,7 +442,8 @@ const NoopProvider = ({ children }: { children: ReactNode }) => children; const createAdapter = ( progressSessions: QueueProgressSession[], liveFollowEnabled: boolean, - pinnedProgressSessionId: string | null + pinnedProgressSessionId: string | null, + galleryValues: Record ): GalleryUiAdapter => ({ ItemActionsProvider: NoopProvider, @@ -376,7 +465,7 @@ const createAdapter = ( toggleItemSelection: noop, updateSettings: noop, }, - galleryValues: {}, + galleryValues, generateValues: {}, getItemLabel: mocks.getItemLabel, liveFollowEnabled, @@ -396,11 +485,16 @@ let host: HTMLDivElement | null = null; let root: Root | null = null; let queryClient: QueryClient | null = null; let currentGallery = createGallery(); +/** The persisted Gallery values; by default the selection was made in the listing the harness shows. */ +let currentGalleryValues: Record = {}; let currentLiveFollowEnabled = false; let currentPinnedSessionId: string | null = null; let currentProgressSessions: QueueProgressSession[] = []; const followProgressSession = vi.fn(); let currentStrip: GalleryStarredStrip = EMPTY_GALLERY_STARRED_STRIP; +let currentSparseListing: GallerySparseListing | undefined; +const setVisibleRange = vi.fn(); +const pinRevealIndex = vi.fn(); const READY_LISTING: GalleryListingState = { error: null, isFetchingMore: false, @@ -447,7 +541,7 @@ const Harness = ({ const contextValue: GalleryWidgetContextValue = { actions: createActions(), boardsState: READY_LISTING, - filter: createFilter(gallery), + filter: getStableFilter(gallery), gallery, itemActions: imageActionMocks, isWindowTruncated: false, @@ -455,7 +549,10 @@ const Harness = ({ loadedItems: mergeGalleryLoadedItems(currentStrip.items, gallery.items), projectName: 'Project', region: 'right', + pinRevealIndex, runtime, + setVisibleRange, + sparseListing: currentSparseListing, starredStrip: currentStrip, } as unknown as GalleryWidgetContextValue; @@ -463,7 +560,9 @@ const Harness = ({ - + @@ -498,6 +597,66 @@ const Harness = ({ ); }; +const QueryBackedGalleryHarness = ({ + gallery, + liveFollowEnabled, + pinnedSessionId, + progressSessions, +}: { + gallery: GalleryStateView; + liveFollowEnabled: boolean; + pinnedSessionId: string | null; + progressSessions: QueueProgressSession[]; +}) => { + const sparseData = useGalleryData({ + galleryView: gallery.galleryView, + page: gallery.page, + projectBoardId: gallery.projectBoardId, + recentImages: [], + searchTerm: gallery.searchTerm, + selectedBoardId: gallery.selectedBoardId, + semanticQuery: gallery.semanticImageQuery, + settings: gallery.settings, + starred: gallery.starredOnly ? true : undefined, + sparseViewport: true, + }); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + const contextValue: GalleryWidgetContextValue = { + actions: createActions(), + boardsState: sparseData.boardsState, + filter: sparseData.filter, + gallery, + itemActions: imageActionMocks, + isWindowTruncated: false, + listing: sparseData.listing, + loadedItems: mergeGalleryLoadedItems(currentStrip.items, sparseData.items ?? []), + projectName: 'Project', + region: 'right', + runtime, + setVisibleRange: sparseData.setVisibleRange, + sparseListing: sparseData.sparseListing, + starredStrip: currentStrip, + } as unknown as GalleryWidgetContextValue; + + return ( + + + + + + + + + + + ); +}; + const interact = (action: () => void, delay = 0): Promise => act(async () => { action(); @@ -526,6 +685,26 @@ const renderGallery = async ( ); }; +const renderQueryBackedGallery = async (gallery: GalleryStateView) => { + currentGallery = gallery; + await interact(() => + root?.render( + + + + + + + + ) + ); +}; + const getButton = (label: string): HTMLButtonElement => { const button = host?.querySelector(`button[aria-label="${label}"]`); @@ -547,11 +726,16 @@ const pointer = (type: string, target: EventTarget, clientX: number, clientY: nu beforeEach(() => { // Drain singleton reveal intent with an unmatchable request so later tests cannot adopt it. - requestGalleryItemReveal('image:__drained__'); + requestReveal('image:__drained__'); accountLifecycle.activate('grid-user'); vi.clearAllMocks(); + mocks.fetchSparsePage.mockReset(); + mocks.fetchSparseTotal.mockReset(); + mocks.getGalleryItemByRef.mockReset(); registeredCommands.clear(); currentGallery = createGallery(); + currentGalleryValues = { selectedBoardId: board.id }; + stableFilters.clear(); mocks.itemProgress = null; mocks.indexAvailability = { modelName: null, state: 'disabled' }; mocks.getItemLabel.mockReset(); @@ -560,6 +744,11 @@ beforeEach(() => { currentPinnedSessionId = null; mocks.progressFrame = null; currentStrip = EMPTY_GALLERY_STARRED_STRIP; + currentSparseListing = undefined; + mocks.useRealGridVirtualizer = false; + mocks.partialRowPosition = null; + mocks.partialRowScrollOffset = null; + mocks.virtualizerOptions.length = 0; currentListing = READY_LISTING; queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); host = document.createElement('div'); @@ -842,6 +1031,76 @@ describe('GalleryImageGrid mixed item cells', () => { expect(actionMocks.setStarredOnly).toHaveBeenCalledExactlyOnceWith(true); }); + it('retains the selected star state for the star hotkey after sparse page eviction', async () => { + const starredItem = createItem('image', 'retained-starred.png', { starred: true }); + const gallery = createGallery({ + items: [starredItem], + selectedItemKey: 'image:retained-starred.png', + selectedItemKeys: ['image:retained-starred.png'], + }); + await renderGallery(gallery); + await renderGallery({ ...gallery, items: [] }); + + registeredCommands.get('gallery.starImage')?.(); + + expect(imageActionMocks.setItemsStarred).toHaveBeenCalledExactlyOnceWith( + [{ kind: 'image', name: 'retained-starred.png' }], + false + ); + }); + + it('reads a starred-only selection as starred where no page has loaded its members', async () => { + const loaded = [ + createItem('image', 'starred-loaded-0.png', { starred: true }), + createItem('image', 'starred-loaded-1.png', { starred: true }), + ]; + const selectedRefs = [...loaded.map(toGalleryItemRef), { kind: 'image' as const, name: 'starred-unloaded.png' }]; + await renderGallery( + createGallery({ + items: loaded, + selectedItemKey: 'image:starred-loaded-0.png', + selectedItemKeys: ['image:starred-loaded-0.png', 'image:starred-loaded-1.png', 'image:starred-unloaded.png'], + selectionStarredOnly: true, + settings: DENSE_SETTINGS, + starredOnly: true, + }) + ); + + registeredCommands.get('gallery.starImage')?.(); + expect(imageActionMocks.setItemsStarred).toHaveBeenCalledExactlyOnceWith(selectedRefs, false); + + await interact(() => + getButton('Select starred-loaded-0.png for preview').dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 23, clientY: 41 }) + ) + ); + expect(JSON.parse(host?.querySelector('[data-testid="context-target"]')?.textContent ?? 'null')).toMatchObject({ + allStarred: true, + itemRefs: selectedRefs, + }); + }); + + it('starts the arrows from the first tile in view for a starred-only selection no loaded page holds', async () => { + const shown = [0, 1, 2].map((index) => createItem('image', `starred-only-${index}.png`, { starred: true })); + const unloaded = createItem('image', 'starred-only-unloaded.png', { starred: true }); + // In scope of this listing, so only the absent strip could claim it. + currentGalleryValues = { selectedBoardId: board.id, selectedImage: unloaded, starredOnly: true }; + await renderGallery( + createGallery({ + items: shown, + primarySelectedItemKey: 'image:starred-only-unloaded.png', + selectedItemKey: null, + selectedItemKeys: ['image:starred-only-unloaded.png'], + settings: DENSE_SETTINGS, + starredOnly: true, + }) + ); + + registeredCommands.get('gallery.galleryNavLeft')?.(); + + expect(actionMocks.selectItem).toHaveBeenCalledExactlyOnceWith(shown[0]); + }); + it('renders same-name media independently and gives a video a static accessible poster', async () => { const gallery = createGallery({ items: [createItem('image', 'shared'), createItem('video', 'shared')], @@ -1116,6 +1375,7 @@ describe('GalleryImageGrid mixed item cells', () => { { kind: 'video', name: 'unloaded.mp4' }, ], items: [{ kind: 'image', name: 'loaded.png' }], + allStarred: false, }) ); }); @@ -1280,6 +1540,54 @@ describe('GalleryImageGrid range selection', () => { expect(mocks.fetchNames).toHaveBeenCalledOnce(); }); + it('keeps the persisted primary selection as the range anchor after its sparse page leaves the viewport', async () => { + mocks.fetchNames.mockResolvedValue({ items: orderedRefs, total: orderedRefs.length }); + const target = rangeItems[2]!; + const gallery = createGallery({ + items: [target], + primarySelectedItemKey: 'image:first.png', + selectedItemKey: null, + selectedItemKeys: ['image:first.png'], + }); + + await renderGallery(gallery); + await click(getButton('Select last.png for preview'), { shiftKey: true }); + + await vi.waitFor(() => expect(actionMocks.selectItemRange).toHaveBeenCalledWith(orderedRefs, target)); + expect(actionMocks.selectItem).not.toHaveBeenCalled(); + }); + + it('keeps an evicted primary as the anchor through a run of Shift+arrows', async () => { + const [anchor, first, second, third] = ['anchor.png', 'run-a.png', 'run-b.png', 'run-c.png'].map((name) => + createItem('image', name) + ); + const refs = [anchor!, first!, second!, third!].map(toGalleryItemRef); + mocks.fetchNames.mockResolvedValue({ items: refs, total: refs.length }); + await renderGallery( + createGallery({ + items: [first!, second!, third!], + primarySelectedItemKey: 'image:anchor.png', + selectedItemKey: null, + selectedItemKeys: ['image:anchor.png'], + settings: DENSE_SETTINGS, + }) + ); + await interact(() => getButton('Select run-a.png for preview').focus()); + + registeredCommands.get('gallery.extendSelectionRight')?.(); + await vi.waitFor(() => expect(actionMocks.selectItemRange).toHaveBeenLastCalledWith(refs.slice(0, 3), second)); + // As the store applies the range: the item it reached becomes the primary. + await renderGallery({ + ...currentGallery, + primarySelectedItemKey: 'image:run-b.png', + selectedItemKey: 'image:run-b.png', + selectedItemKeys: ['image:anchor.png', 'image:run-a.png', 'image:run-b.png'], + }); + + registeredCommands.get('gallery.extendSelectionRight')?.(); + await vi.waitFor(() => expect(actionMocks.selectItemRange).toHaveBeenLastCalledWith(refs, third)); + }); + it('reuses the date board name list already in the query cache', async () => { const gallery = createGallery({ boards: [{ ...board, id: 'by_date:2026-07-30', kind: 'date' }], @@ -1453,12 +1761,12 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createGallery(); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:first.png')); + await interact(() => requestReveal('image:first.png')); expect(mocks.scrollToIndex).toHaveBeenCalledTimes(1); // Re-clicking the same map point after scrolling away must reveal again // even though the selection is unchanged. - await interact(() => requestGalleryItemReveal('image:first.png')); + await interact(() => requestReveal('image:first.png')); expect(mocks.scrollToIndex).toHaveBeenCalledTimes(2); }); @@ -1467,7 +1775,7 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createGallery({ items, selectedItemKey: null, selectedItemKeys: [] }); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:deep.png')); + await interact(() => requestReveal('image:deep.png')); expect(mocks.scrollToIndex).not.toHaveBeenCalled(); // The page lands and the revealed item appears: scroll exactly then. @@ -1482,7 +1790,7 @@ describe('GalleryImageGrid reveal requests', () => { it('honors a reveal requested before this grid mounted, while its item is still selected', async () => { // A grid mounted by a reveal must honor the request that preceded its mount. - await interact(() => requestGalleryItemReveal('image:last.png')); + await interact(() => requestReveal('image:last.png')); expect(mocks.scrollToIndex).not.toHaveBeenCalled(); await renderGallery(createGallery({ selectedItemKey: 'image:last.png', selectedItemKeys: ['image:last.png'] })); @@ -1492,12 +1800,258 @@ describe('GalleryImageGrid reveal requests', () => { it('ignores a reveal requested before mount once the selection has moved on', async () => { // Age is not what makes a request stale — a superseding selection is. - await interact(() => requestGalleryItemReveal('image:last.png')); + await interact(() => requestReveal('image:last.png')); await renderGallery(createGallery({ selectedItemKey: 'image:first.png', selectedItemKeys: ['image:first.png'] })); expect(mocks.scrollToIndex).not.toHaveBeenCalled(); }); + it('ignores an absolute-index reveal from the previous account before this grid mounts', async () => { + accountLifecycle.activate('grid-user-a'); + await interact(() => requestReveal('image:account-a-deep.png', 70)); + accountLifecycle.activate('grid-user-b'); + + await renderGallery( + createGallery({ + items: [], + selectedItemKey: null, + selectedItemKeys: [], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + + expect(setVisibleRange).not.toHaveBeenCalled(); + expect(mocks.scrollToIndex).not.toHaveBeenCalled(); + }); + + it('retires an already-pending reveal when the mounted grid changes accounts', async () => { + const firstPageItem = createItem('image', 'account-a-first.png'); + currentSparseListing = { + itemSlots: new Map([[0, firstPageItem]]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + const gallery = createGallery({ + items: [firstPageItem], + selectedItemKey: null, + selectedItemKeys: [], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + await renderGallery(gallery); + await interact(() => requestReveal('image:account-a-deep.png', 70)); + expect(setVisibleRange).toHaveBeenCalled(); + expect(mocks.scrollToIndex).toHaveBeenCalled(); + + setVisibleRange.mockClear(); + mocks.scrollToIndex.mockClear(); + accountLifecycle.activate('grid-user-b'); + const accountBItem = createItem('image', 'account-b-deep.png'); + currentSparseListing = { + itemSlots: new Map([[70, accountBItem]]), + pageStates: new Map([[60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery({ ...gallery, items: [accountBItem] }); + + expect(setVisibleRange).not.toHaveBeenCalled(); + expect(mocks.scrollToIndex).not.toHaveBeenCalled(); + }); + + it('waits for a loading page instead of stepping over it', async () => { + const item59 = createItem('image', 'item-59.png'); + const item60 = createItem('image', 'item-60.png'); + const item120 = createItem('image', 'item-120.png'); + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + currentSparseListing = { + itemSlots: new Map([ + [59, item59], + [120, item120], + ]), + pageStates: new Map([ + [0, settled], + [60, { ...settled, isLoading: true }], + [120, settled], + ]), + recentItems: [], + total: 180, + }; + const gallery = createGallery({ + items: [item59, item120], + selectedItemKey: 'image:item-59.png', + selectedItemKeys: ['image:item-59.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + await renderGallery(gallery); + setVisibleRange.mockClear(); + + await interact(() => registeredCommands.get('gallery.galleryNavRight')?.()); + + expect(actionMocks.selectItem).not.toHaveBeenCalled(); + // The loading page is already subscribed, so the range around the cursor stays. + expect(setVisibleRange).not.toHaveBeenCalledWith({ endIndexExclusive: 120, startIndex: 60 }); + + currentSparseListing = { + ...currentSparseListing, + itemSlots: new Map([ + [59, item59], + [60, item60], + [120, item120], + ]), + pageStates: new Map([ + [0, settled], + [60, settled], + [120, settled], + ]), + }; + await renderGallery({ ...gallery, items: [item59, item60, item120] }); + + expect(actionMocks.selectItem).toHaveBeenCalledExactlyOnceWith(item60, 1); + }); + + it('drops a step whose loading page settles without its target, so a later refresh cannot land it', async () => { + const item59 = createItem('image', 'item-59.png'); + const item61 = createItem('image', 'item-61.png'); + const late = createItem('image', 'late.png'); + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + const listing = (slots: [number, GalleryItem][], pageSixtyLoading: boolean): GallerySparseListing => ({ + itemSlots: new Map(slots), + pageStates: new Map([ + [0, settled], + [60, { ...settled, isLoading: pageSixtyLoading }], + ]), + recentItems: [], + total: 120, + }); + currentSparseListing = listing([[59, item59]], true); + const gallery = createGallery({ + items: [item59], + selectedItemKey: 'image:item-59.png', + selectedItemKeys: ['image:item-59.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + await renderGallery(gallery); + await interact(() => registeredCommands.get('gallery.galleryNavRight')?.()); + + // Position 60 turns out to be a gap. + currentSparseListing = listing( + [ + [59, item59], + [61, item61], + ], + false + ); + await renderGallery({ ...gallery, items: [item59, item61] }); + currentSparseListing = listing( + [ + [59, item59], + [60, late], + [61, item61], + ], + false + ); + await renderGallery({ ...gallery, items: [item59, late, item61] }); + + expect(actionMocks.selectItem).not.toHaveBeenCalled(); + }); + + it('reveals by key, not by index, when the selection navigates a listing the grid no longer shows', async () => { + const deep = createItem('image', 'deep.png'); + const gallery = createGallery({ + primarySelectedItemKey: 'image:deep.png', + selectedItemKey: null, + selectedItemKeys: ['image:deep.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + // Preview steps through the listing the selection was made in, from before the grid's order flipped. + currentGalleryValues = { + selectedBoardId: board.id, + selectedImageQuery: { + boardId: board.id, + galleryView: 'images', + imageOrderDir: gallery.settings.imageOrderDir === 'DESC' ? 'ASC' : 'DESC', + page: 1, + paginationMode: 'infinite', + searchTerm: '', + starredOnly: false, + }, + }; + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + currentSparseListing = { + itemSlots: new Map([[0, createItem('image', 'page-zero.png')]]), + pageStates: new Map([[0, settled]]), + recentItems: [], + total: 120, + }; + await renderGallery(gallery); + + await interact(() => requestReveal('image:deep.png', 70)); + + expect(pinRevealIndex).not.toHaveBeenCalled(); + expect(setVisibleRange).not.toHaveBeenCalled(); + expect(mocks.scrollToIndex).not.toHaveBeenCalled(); + + currentSparseListing = { + itemSlots: new Map([ + [0, createItem('image', 'page-zero.png')], + [10, deep], + ]), + pageStates: new Map([[0, settled]]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [deep] }); + + expect(mocks.scrollToIndex).toHaveBeenCalledOnce(); + }); + + it('pins a verified reveal index past a stale total and scrolls to it once its page lands', async () => { + const firstPageItem = createItem('image', 'first.png'); + const added = createItem('image', 'added.png'); + const pageState = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + // Another client added item 61 after this grid counted 60; Find in Gallery located it at index 60. + currentSparseListing = { + itemSlots: new Map([[0, firstPageItem]]), + pageStates: new Map([[0, pageState]]), + recentItems: [], + total: 60, + }; + const gallery = createGallery({ + items: [firstPageItem], + selectedItemKey: null, + selectedItemKeys: ['image:added.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + await renderGallery(gallery); + setVisibleRange.mockClear(); + mocks.scrollToIndex.mockClear(); + + await interact(() => requestReveal('image:added.png', 60)); + + expect(pinRevealIndex).toHaveBeenCalledExactlyOnceWith(60); + expect(setVisibleRange).toHaveBeenCalledWith({ endIndexExclusive: 60, startIndex: 60 }); + + mocks.scrollToIndex.mockClear(); + currentSparseListing = { + itemSlots: new Map([ + [0, firstPageItem], + [60, added], + ]), + pageStates: new Map([ + [0, pageState], + [60, pageState], + ]), + recentItems: [], + total: 61, + }; + await renderGallery({ ...gallery, items: [firstPageItem, added] }); + + // The pending reveal scrolls by key only once the item's slot exists. + expect(mocks.scrollToIndex).toHaveBeenCalledOnce(); + }); + it('reveals a starred item in the strip, and keeps the reveal pending while the strip is collapsed', async () => { const starred = createItem('image', 'starred.png', { starred: true }); setStrip([starred]); @@ -1509,7 +2063,7 @@ describe('GalleryImageGrid reveal requests', () => { }); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:starred.png')); + await interact(() => requestReveal('image:starred.png')); // Loaded but row-less under the collapsed disclosure: consuming the // reveal here would silently drop it. expect(mocks.scrollToIndex).not.toHaveBeenCalled(); @@ -1537,7 +2091,7 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createOffPageGallery(2); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:deep.png')); + await interact(() => requestReveal('image:deep.png')); expect(mocks.setPage).toHaveBeenCalledWith(2); expect(mocks.scrollToIndex).not.toHaveBeenCalled(); @@ -1557,7 +2111,7 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createOffPageGallery(2); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:deep.png')); + await interact(() => requestReveal('image:deep.png')); expect(mocks.setPage).toHaveBeenCalledTimes(1); // The stamped page arrives without the item, then the user pages away. @@ -1571,7 +2125,7 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createOffPageGallery(null); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:deep.png')); + await interact(() => requestReveal('image:deep.png')); expect(mocks.setPage).not.toHaveBeenCalled(); }); @@ -1580,7 +2134,7 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createOffPageGallery(null); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:deep.png')); + await interact(() => requestReveal('image:deep.png')); // An off-page auto-select replaces the persisted selection. await renderGallery({ @@ -1604,7 +2158,7 @@ describe('GalleryImageGrid reveal requests', () => { const gallery = createGallery({ items, selectedItemKey: null, selectedItemKeys: [] }); await renderGallery(gallery); - await interact(() => requestGalleryItemReveal('image:deep.png')); + await interact(() => requestReveal('image:deep.png')); // The user picks something else before the reveal's page arrives. await renderGallery({ ...gallery, selectedItemKey: 'image:first.png', selectedItemKeys: ['image:first.png'] }); @@ -1621,6 +2175,1416 @@ describe('GalleryImageGrid reveal requests', () => { }); describe('GalleryImageGrid virtualization', () => { + it('stamps a selected semantic result with its absolute sparse page', async () => { + const result = createItem('image', 'semantic-180.png'); + currentSparseListing = { + itemSlots: new Map([[180, result]]), + pageStates: new Map([[180, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 181, + }; + + await renderGallery( + createGallery({ + items: [result], + semanticImageQuery: { kind: 'text', query: 'sunset' }, + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await click(getButton('Select semantic-180.png for preview')); + + expect(actionMocks.selectItem).toHaveBeenCalledWith(result, 3); + }); + + it('stamps a semantic result toggled by Ctrl-click or hotkey with its absolute sparse page', async () => { + const primary = createItem('image', 'semantic-3.png'); + const result = createItem('image', 'semantic-125.png'); + currentSparseListing = { + itemSlots: new Map([ + [3, primary], + [125, result], + ]), + pageStates: new Map([[120, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 181, + }; + + await renderGallery( + createGallery({ + items: [primary, result], + selectedItemKey: 'image:semantic-3.png', + selectedItemKeys: ['image:semantic-3.png'], + semanticImageQuery: { kind: 'text', query: 'sunset' }, + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await click(getButton('Select semantic-125.png for preview'), { ctrlKey: true }); + + expect(actionMocks.toggleItemInSelection).toHaveBeenCalledExactlyOnceWith(result, null, 2); + + // The toggle hotkey resolves the focused tile's page itself. + actionMocks.toggleItemInSelection.mockClear(); + await interact(() => getButton('Select semantic-125.png for preview').focus()); + await interact(() => registeredCommands.get('gallery.toggleFocusedInSelection')?.()); + + expect(actionMocks.toggleItemInSelection).toHaveBeenCalledExactlyOnceWith(result, null, 2); + }); + + it('keeps the rest of the selection when Ctrl-click removes a primary whose next primary is unloaded', async () => { + const pageOne = createItem('image', 'page-one.png'); + const pageSix = createItem('image', 'page-six.png'); + currentSparseListing = { + itemSlots: new Map([[300, pageSix]]), + pageStates: new Map([[300, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 400, + }; + mocks.getGalleryItemByRef.mockResolvedValue(pageOne); + + await renderGallery( + createGallery({ + items: [pageSix], + selectedItemKey: 'image:page-six.png', + selectedItemKeys: ['image:page-one.png', 'image:page-six.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await click(getButton('Select page-six.png for preview'), { ctrlKey: true }); + + await vi.waitFor(() => expect(actionMocks.toggleItemInSelection).toHaveBeenCalledExactlyOnceWith(pageSix, pageOne)); + expect(mocks.getGalleryItemByRef).toHaveBeenCalledWith({ kind: 'image', name: 'page-one.png' }, expect.anything()); + }); + + it('still toggles when the unloaded next primary cannot be resolved', async () => { + const pageSix = createItem('image', 'page-six.png'); + currentSparseListing = { + itemSlots: new Map([[300, pageSix]]), + pageStates: new Map([[300, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 400, + }; + mocks.getGalleryItemByRef.mockRejectedValue(new Error('Not found')); + + await renderGallery( + createGallery({ + items: [pageSix], + selectedItemKey: 'image:page-six.png', + selectedItemKeys: ['image:deleted-elsewhere.png', 'image:page-six.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await click(getButton('Select page-six.png for preview'), { ctrlKey: true }); + + await vi.waitFor(() => expect(actionMocks.toggleItemInSelection).toHaveBeenCalledExactlyOnceWith(pageSix, null)); + }); + + it('abandons an unloaded next-primary lookup when the grid unmounts', async () => { + const pageSix = createItem('image', 'page-six.png'); + let lookupSignal: AbortSignal | undefined; + currentSparseListing = { + itemSlots: new Map([[300, pageSix]]), + pageStates: new Map([[300, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 400, + }; + // Rejects on abort as a real fetch does, so the failure fallback must recognize the abort. + mocks.getGalleryItemByRef.mockImplementation((_ref, signal?: AbortSignal) => { + lookupSignal = signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + + await renderGallery( + createGallery({ + items: [pageSix], + selectedItemKey: 'image:page-six.png', + selectedItemKeys: ['image:page-one.png', 'image:page-six.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await click(getButton('Select page-six.png for preview'), { ctrlKey: true }); + await act(async () => { + root?.unmount(); + await Promise.resolve(); + }); + root = null; + + expect(lookupSignal?.aborted).toBe(true); + expect(actionMocks.toggleItemInSelection).not.toHaveBeenCalled(); + }); + + it('drops an unloaded next-primary toggle when the selection changes before it resolves', async () => { + const pageOne = createItem('image', 'page-one.png'); + const pageSix = createItem('image', 'page-six.png'); + let resolveItem!: (item: GalleryItem) => void; + currentSparseListing = { + itemSlots: new Map([[300, pageSix]]), + pageStates: new Map([[300, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 400, + }; + mocks.getGalleryItemByRef.mockReturnValue( + new Promise((resolve) => { + resolveItem = resolve; + }) + ); + const gallery = createGallery({ + items: [pageSix], + selectedItemKey: 'image:page-six.png', + selectedItemKeys: ['image:page-one.png', 'image:page-six.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + + await renderGallery(gallery); + await click(getButton('Select page-six.png for preview'), { ctrlKey: true }); + await renderGallery({ ...gallery, selectedItemKeys: ['image:page-six.png'] }); + await interact(() => resolveItem(pageOne)); + + expect(actionMocks.toggleItemInSelection).not.toHaveBeenCalled(); + }); + + it('stamps the next primary with its sparse page when Ctrl-click removes the primary', async () => { + const remaining = createItem('image', 'semantic-70.png'); + const primary = createItem('image', 'semantic-125.png'); + currentSparseListing = { + itemSlots: new Map([ + [70, remaining], + [125, primary], + ]), + pageStates: new Map([[60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 181, + }; + + await renderGallery( + createGallery({ + items: [remaining, primary], + selectedItemKey: 'image:semantic-125.png', + selectedItemKeys: ['image:semantic-70.png', 'image:semantic-125.png'], + semanticImageQuery: { kind: 'text', query: 'sunset' }, + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await click(getButton('Select semantic-125.png for preview'), { ctrlKey: true }); + + expect(actionMocks.toggleItemInSelection).toHaveBeenCalledExactlyOnceWith(primary, remaining, 1); + }); + + it('keeps hydration gaps inside the selected paginated page', async () => { + const first = createItem('image', 'page-2-first.png'); + const third = createItem('image', 'page-2-third.png'); + currentSparseListing = { + itemSlots: new Map([ + [0, first], + [2, third], + ]), + pageStates: new Map([[120, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 130, + }; + + await renderGallery( + createGallery({ + items: [first, third], + page: 2, + selectedItemKey: 'image:page-2-first.png', + selectedItemKeys: ['image:page-2-first.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'paginated' }, + }) + ); + + expect(host?.querySelector('img[alt="page-2-first.png"]')).not.toBeNull(); + expect(host?.querySelector('img[alt="page-2-third.png"]')).not.toBeNull(); + expect(host?.querySelectorAll('[data-gallery-slot-state="empty"]')).toHaveLength(8); + expect(mocks.virtualizerOptions.at(-1)?.count).toBe(4); + }); + + it('keeps page zero active while ascending recent-only rows need count discovery', async () => { + const recent = createItem('image', 'recent.png'); + currentSparseListing = { + itemSlots: new Map(), + pageStates: new Map([[0, { error: null, isLoading: true, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [recent], + total: null, + }; + + await renderGallery( + createGallery({ + items: [recent], + settings: { ...DENSE_SETTINGS, imageOrderDir: 'ASC', paginationMode: 'infinite' }, + }) + ); + + const options = mocks.virtualizerOptions.at(-1); + expect(options?.onChange).toBeDefined(); + + await interact(() => + options?.onChange?.({ + getVirtualItems: () => [{ index: 0 }], + range: { endIndex: 0, startIndex: 0 }, + }) + ); + + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 60, startIndex: 0 }); + }); + + it('shows an accessible loading status inside a sparse slot', async () => { + currentSparseListing = { + itemSlots: new Map(), + pageStates: new Map([[0, { error: null, isLoading: true, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 12, + }; + + await renderGallery(createGallery({ settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } })); + + const status = host?.querySelector('[data-gallery-slot-state="loading"] [role="status"]'); + expect(status).not.toBeNull(); + expect(status?.getAttribute('aria-label')).toBe('Loading gallery'); + expect(status?.textContent).toContain('Loading gallery'); + expect(status?.getBoundingClientRect().width).toBeGreaterThan(0); + }); + + it('keeps a sparse-page Retry on a fully visible row when the first visible row is clipped', async () => { + const pageError = new Error('Page unavailable'); + const retry = vi.fn(() => Promise.resolve()); + currentSparseListing = { + itemSlots: new Map(), + pageStates: new Map([[0, { error: pageError, isLoading: false, retry }]]), + recentItems: [], + total: 120, + }; + mocks.partialRowPosition = { ratio: 0.8, rowIndex: 0, visibleRows: 3 }; + + await renderGallery(createGallery({ items: [], settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } })); + + const viewport = host!.querySelector('[data-part="viewport"]')!; + + await interact(() => { + viewport.scrollTop = mocks.partialRowScrollOffset ?? 0; + viewport.dispatchEvent(new Event('scroll')); + }); + await interact(noop); + + const retryButton = host!.querySelector('[data-gallery-slot-state="error"] button')!; + const retryCell = retryButton.closest('[role="listitem"]')!; + const retryRect = retryCell.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + + expect(retryRect.top).toBeGreaterThanOrEqual(viewportRect.top - 1); + expect(retryRect.bottom).toBeLessThanOrEqual(viewportRect.bottom + 1); + + await click(retryButton); + expect(retry).toHaveBeenCalledOnce(); + }); + + it('renders absolute sparse slots, page-local retry, and recent overlays at the sort edge', async () => { + const first = createItem('image', 'sparse-120.png'); + const third = createItem('image', 'sparse-122.png'); + const recent = createItem('image', 'recent.png'); + const retry = vi.fn(() => Promise.resolve()); + const pageError = new Error('Page unavailable'); + currentSparseListing = { + itemSlots: new Map([ + [120, first], + [122, third], + ]), + pageStates: new Map([[120, { error: pageError, isLoading: false, retry }]]), + recentItems: [recent], + total: 130, + }; + + await renderGallery( + createGallery({ + items: [first, third, recent], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite', imageOrderDir: 'DESC' }, + }) + ); + + const descendingSections = [...(host?.querySelectorAll('[data-gallery-section]') ?? [])]; + expect(descendingSections[0]?.dataset.gallerySection).toBe('recent'); + expect(descendingSections.at(-1)?.dataset.gallerySection).toBe('regular'); + expect(host?.querySelector('img[alt="sparse-120.png"]')).not.toBeNull(); + expect(host?.querySelector('img[alt="sparse-122.png"]')).not.toBeNull(); + expect(host?.querySelectorAll('[data-gallery-slot-state="error"]')).toHaveLength(1); + expect(host?.querySelectorAll('[data-gallery-slot-state="error"] button')).toHaveLength(1); + expect(host?.querySelectorAll('[data-gallery-page-error] button')).toHaveLength(0); + + const localRetry = host!.querySelector('[data-gallery-slot-state="error"] button')!; + expect(localRetry.closest('[role="listitem"]')?.textContent).toContain('Page unavailable'); + await click(localRetry); + expect(retry).toHaveBeenCalledTimes(1); + + await renderGallery( + createGallery({ + items: [first, third, recent], + settings: { ...DENSE_SETTINGS, imageOrderDir: 'ASC', paginationMode: 'infinite' }, + }) + ); + const ascendingSections = [...(host?.querySelectorAll('[data-gallery-section]') ?? [])]; + expect(ascendingSections[0]?.dataset.gallerySection).toBe('regular'); + expect(ascendingSections.at(-1)?.dataset.gallerySection).toBe('recent'); + }); + + it('reconciles ascending recent overlays by requesting the backend tail page', async () => { + const recent = createItem('image', 'recent-tail.png'); + currentSparseListing = { + itemSlots: new Map(), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [recent], + total: 120, + }; + + await renderGallery( + createGallery({ + items: [recent], + settings: { ...DENSE_SETTINGS, imageOrderDir: 'ASC', paginationMode: 'infinite' }, + }) + ); + expect(host?.querySelectorAll('img[alt="recent-tail.png"]')).toHaveLength(1); + + const options = mocks.virtualizerOptions.at(-1)!; + await interact(() => + options.onChange?.({ + getVirtualItems: () => [{ index: options.count - 1 }], + range: { endIndex: options.count - 1, startIndex: options.count - 1 }, + }) + ); + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 120, startIndex: 60 }); + + currentSparseListing = { + itemSlots: new Map([[119, recent]]), + pageStates: new Map([[60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + await renderGallery(currentGallery); + expect(host?.querySelectorAll('img[alt="recent-tail.png"]')).toHaveLength(1); + }); + + it('loads and selects the next absolute slot on keyboard navigation, while select-all excludes gaps', async () => { + const lastInPage = createItem('image', 'page-0-last.png'); + const firstNextPage = createItem('image', 'page-1-first.png'); + currentSparseListing = { + itemSlots: new Map([[59, lastInPage]]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery( + createGallery({ + items: [lastInPage], + selectedItemKey: 'image:page-0-last.png', + selectedItemKeys: ['image:page-0-last.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + + const lastTile = getButton('Select page-0-last.png for preview'); + await interact(() => lastTile.focus()); + await act(() => userEvent.keyboard('{ArrowRight}')); + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 120, startIndex: 0 }); + expect(mocks.scrollToIndex).toHaveBeenCalled(); + expect(document.activeElement).toBe(lastTile); + + currentSparseListing = { + itemSlots: new Map([ + [59, lastInPage], + [60, firstNextPage], + ]), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [lastInPage, firstNextPage] }); + expect(actionMocks.selectItem).toHaveBeenLastCalledWith(firstNextPage, 1); + expect(document.activeElement).toBe(getButton('Select page-1-first.png for preview')); + + registeredCommands.get('gallery.selectAllOnPage')?.(); + expect(actionMocks.selectItemRange).toHaveBeenLastCalledWith( + [ + { kind: 'image', name: 'page-0-last.png' }, + { kind: 'image', name: 'page-1-first.png' }, + ], + lastInPage, + 0 + ); + }); + + it('loads and extends a sparse range across an unloaded page boundary', async () => { + const lastInPage = createItem('image', 'range-page-0-last.png'); + const firstNextPage = createItem('image', 'range-page-1-first.png'); + mocks.fetchNames.mockResolvedValue({ + items: [toGalleryItemRef(lastInPage), toGalleryItemRef(firstNextPage)], + total: 2, + }); + currentSparseListing = { + itemSlots: new Map([[59, lastInPage]]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery( + createGallery({ + items: [lastInPage], + selectedItemKey: 'image:range-page-0-last.png', + selectedItemKeys: ['image:range-page-0-last.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await interact(() => getButton('Select range-page-0-last.png for preview').focus()); + registeredCommands.get('gallery.extendSelectionRight')?.(); + + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 120, startIndex: 0 }); + currentSparseListing = { + itemSlots: new Map([ + [59, lastInPage], + [60, firstNextPage], + ]), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [lastInPage, firstNextPage] }); + + await vi.waitFor(() => + expect(actionMocks.selectItemRange).toHaveBeenLastCalledWith( + [toGalleryItemRef(lastInPage), toGalleryItemRef(firstNextPage)], + firstNextPage, + 1 + ) + ); + expect(document.activeElement).toBe(getButton('Select range-page-1-first.png for preview')); + }); + + it('extends a sparse range across an unloaded date-board page with cached names', async () => { + const lastInPage = createItem('image', 'date-range-page-0-last.png'); + const firstNextPage = createItem('image', 'date-range-page-1-first.png'); + const dateBoardId = 'by_date:2026-07-30'; + const gallery = createGallery({ + boards: [{ ...board, id: dateBoardId, kind: 'date' }], + items: [lastInPage], + selectedBoardId: dateBoardId, + selectedItemKey: 'image:date-range-page-0-last.png', + selectedItemKeys: ['image:date-range-page-0-last.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + const filter = { + boardId: dateBoardId, + galleryView: gallery.galleryView, + orderDir: gallery.settings.imageOrderDir, + searchTerm: '', + }; + const orderedRefs = [toGalleryItemRef(lastInPage), toGalleryItemRef(firstNextPage)]; + queryClient?.setQueryData(getNamesKey(filter), { items: orderedRefs, total: 120 }); + currentSparseListing = { + itemSlots: new Map([[59, lastInPage]]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery(gallery); + await interact(() => getButton('Select date-range-page-0-last.png for preview').focus()); + registeredCommands.get('gallery.extendSelectionRight')?.(); + + currentSparseListing = { + itemSlots: new Map([ + [59, lastInPage], + [60, firstNextPage], + ]), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [lastInPage, firstNextPage] }); + + await vi.waitFor(() => expect(actionMocks.selectItemRange).toHaveBeenLastCalledWith(orderedRefs, firstNextPage, 1)); + expect(document.activeElement).toBe(getButton('Select date-range-page-1-first.png for preview')); + expect(mocks.fetchNames).not.toHaveBeenCalled(); + }); + + it('loads an unloaded page for focus-only navigation without changing selection', async () => { + const lastInPage = createItem('image', 'focus-page-0-last.png'); + const firstNextPage = createItem('image', 'focus-page-1-first.png'); + currentSparseListing = { + itemSlots: new Map([[59, lastInPage]]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery( + createGallery({ + items: [lastInPage], + selectedItemKey: 'image:focus-page-0-last.png', + selectedItemKeys: ['image:focus-page-0-last.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await interact(() => getButton('Select focus-page-0-last.png for preview').focus()); + registeredCommands.get('gallery.moveFocusRight')?.(); + + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 120, startIndex: 0 }); + currentSparseListing = { + itemSlots: new Map([ + [59, lastInPage], + [60, firstNextPage], + ]), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [lastInPage, firstNextPage] }); + + expect(document.activeElement).toBe(getButton('Select focus-page-1-first.png for preview')); + expect(actionMocks.selectItem).not.toHaveBeenCalled(); + }); + + it.each(['backend-ordered names', 'materialized fallback', 'error fallback'] as const)( + 'cancels a hydrated range when focus moves during the %s path', + async (resolutionPath) => { + const previousItem = createItem('image', 'hydrated-cancel-page-0-previous.png'); + const anchorItem = createItem('image', 'hydrated-cancel-page-0-anchor.png'); + const pendingItem = createItem('image', 'hydrated-cancel-page-1-pending.png'); + let resolveNames: ((value: { items: GalleryItemRef[]; total: number }) => void) | null = null; + let rejectNames: ((reason?: unknown) => void) | null = null; + mocks.fetchNames.mockReturnValue( + new Promise((resolve, reject) => { + resolveNames = resolve; + rejectNames = reject; + }) + ); + currentSparseListing = { + itemSlots: new Map([ + [58, previousItem], + [59, anchorItem], + ]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery( + createGallery({ + items: [previousItem, anchorItem], + selectedItemKey: 'image:hydrated-cancel-page-0-anchor.png', + selectedItemKeys: ['image:hydrated-cancel-page-0-anchor.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await interact(() => getButton('Select hydrated-cancel-page-0-anchor.png for preview').focus()); + registeredCommands.get('gallery.extendSelectionRight')?.(); + + currentSparseListing = { + itemSlots: new Map([ + [58, previousItem], + [59, anchorItem], + [60, pendingItem], + ]), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [previousItem, anchorItem, pendingItem] }); + await vi.waitFor(() => expect(mocks.fetchNames).toHaveBeenCalledOnce()); + + registeredCommands.get('gallery.moveFocusLeft')?.(); + expect(document.activeElement).toBe(getButton('Select hydrated-cancel-page-0-anchor.png for preview')); + if (resolutionPath === 'error fallback') { + await interact(() => rejectNames?.(new Error('Names unavailable'))); + } else { + await interact(() => + resolveNames?.({ + items: + resolutionPath === 'backend-ordered names' + ? [toGalleryItemRef(previousItem), toGalleryItemRef(anchorItem), toGalleryItemRef(pendingItem)] + : [], + total: 120, + }) + ); + } + + expect(actionMocks.selectItemRange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(getButton('Select hydrated-cancel-page-0-anchor.png for preview')); + } + ); + + it('drops a pending range when a newer focus-only move supersedes it', async () => { + const previousItem = createItem('image', 'supersede-page-0-previous.png'); + const anchorItem = createItem('image', 'supersede-page-0-anchor.png'); + const pendingItem = createItem('image', 'supersede-page-1-pending.png'); + currentSparseListing = { + itemSlots: new Map([ + [58, previousItem], + [59, anchorItem], + ]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + + await renderGallery( + createGallery({ + items: [previousItem, anchorItem], + selectedItemKey: 'image:supersede-page-0-anchor.png', + selectedItemKeys: ['image:supersede-page-0-anchor.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await interact(() => getButton('Select supersede-page-0-anchor.png for preview').focus()); + registeredCommands.get('gallery.extendSelectionRight')?.(); + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 120, startIndex: 0 }); + + registeredCommands.get('gallery.moveFocusLeft')?.(); + expect(document.activeElement).toBe(getButton('Select supersede-page-0-previous.png for preview')); + + currentSparseListing = { + itemSlots: new Map([ + [58, previousItem], + [59, anchorItem], + [60, pendingItem], + ]), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [previousItem, anchorItem, pendingItem] }); + + expect(actionMocks.selectItemRange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(getButton('Select supersede-page-0-previous.png for preview')); + }); + + it('keeps the origin page subscribed while an arrow step loads the next page, so the step lands', async () => { + const lastInPage = createItem('image', 'origin-page-0-last.png'); + const firstNextPage = createItem('image', 'origin-page-1-first.png'); + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + const loading = { ...settled, isLoading: true }; + // As the data layer does: only pages the requested range covers stay subscribed, and the visible selection key + // leaves with its page. + const renderRequestedPages = async (nextPageItem: GalleryItem | null) => { + const [{ endIndexExclusive, startIndex }] = setVisibleRange.mock.lastCall as [ + { endIndexExclusive: number; startIndex: number }, + ]; + const keepsOrigin = startIndex < 60; + const itemSlots = new Map(keepsOrigin ? [[59, lastInPage]] : []); + const pageStates = new Map(keepsOrigin ? [[0, settled]] : []); + + if (endIndexExclusive > 60) { + pageStates.set(60, nextPageItem ? settled : loading); + } + if (nextPageItem) { + itemSlots.set(60, nextPageItem); + } + + currentSparseListing = { itemSlots, pageStates, recentItems: [], total: 120 }; + await renderGallery({ + ...currentGallery, + items: [...itemSlots.values()], + selectedItemKey: keepsOrigin ? 'image:origin-page-0-last.png' : null, + }); + }; + currentSparseListing = { + itemSlots: new Map([[59, lastInPage]]), + pageStates: new Map([[0, settled]]), + recentItems: [], + total: 120, + }; + await renderGallery( + createGallery({ + items: [lastInPage], + selectedItemKey: 'image:origin-page-0-last.png', + selectedItemKeys: ['image:origin-page-0-last.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + const originTile = getButton('Select origin-page-0-last.png for preview'); + await interact(() => originTile.focus()); + + await act(() => userEvent.keyboard('{ArrowRight}')); + await renderRequestedPages(null); + expect(document.activeElement).toBe(originTile); + await renderRequestedPages(firstNextPage); + + expect(actionMocks.selectItem).toHaveBeenLastCalledWith(firstNextPage, 1); + expect(document.activeElement).toBe(getButton('Select origin-page-1-first.png for preview')); + }); + + it('does not subscribe the pages between a step and an origin loaded far down the listing', async () => { + const pinned = createItem('image', 'far-origin-starred.png', { starred: true }); + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + setStrip([pinned]); + const listed = createItem('image', 'far-origin-listed.png'); + // The listing shows page 120; the strip's item is also loaded deep in the listing, where its slot is the step + // origin's. Stepping out of the strip lands on the unloaded row before page 120. + currentSparseListing = { + itemSlots: new Map([ + [120, listed], + [6_000, pinned], + ]), + pageStates: new Map([ + [120, settled], + [6_000, settled], + ]), + recentItems: [], + total: 12_000, + }; + await renderGallery( + createGallery({ + items: [listed, pinned], + selectedItemKey: 'image:far-origin-starred.png', + selectedItemKeys: ['image:far-origin-starred.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + const stripTile = host!.querySelector( + '[data-gallery-section="starred"] button[aria-label="Select far-origin-starred.png for preview"]' + )!; + await interact(() => stripTile.focus()); + setVisibleRange.mockClear(); + + await act(() => userEvent.keyboard('{ArrowRight}')); + + expect(setVisibleRange).toHaveBeenCalledExactlyOnceWith({ endIndexExclusive: 120, startIndex: 60 }); + }); + + it('lands a step whose origin page leaves the viewport while the next page loads', async () => { + const lastInPage = createItem('image', 'scrolled-page-0-last.png'); + const firstNextPage = createItem('image', 'scrolled-page-1-first.png'); + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + currentSparseListing = { + itemSlots: new Map([[59, lastInPage]]), + pageStates: new Map([[0, settled]]), + recentItems: [], + total: 120, + }; + await renderGallery( + createGallery({ + items: [lastInPage], + selectedItemKey: 'image:scrolled-page-0-last.png', + selectedItemKeys: ['image:scrolled-page-0-last.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + + // Run from elsewhere, then the view scrolls on: the selection's page unsubscribes and its visible key clears. + registeredCommands.get('gallery.galleryNavRight')?.(); + currentSparseListing = { + itemSlots: new Map(), + pageStates: new Map([[60, { ...settled, isLoading: true }]]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [], selectedItemKey: null }); + currentSparseListing = { + itemSlots: new Map([[60, firstNextPage]]), + pageStates: new Map([[60, settled]]), + recentItems: [], + total: 120, + }; + await renderGallery({ ...currentGallery, items: [firstNextPage] }); + + expect(actionMocks.selectItem).toHaveBeenCalledExactlyOnceWith(firstNextPage, 1); + }); + + it('hands focus to the first tile in view when the focused tile leaves with its page', async () => { + const items = Array.from({ length: 120 }, (_, index) => createItem('image', `evicted-focus-${index}.png`)); + const settled = { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }; + currentSparseListing = { + itemSlots: new Map(items.map((item, index) => [index, item])), + pageStates: new Map([ + [0, settled], + [60, settled], + ]), + recentItems: [], + total: items.length, + }; + await renderGallery( + createGallery({ + items, + selectedItemKey: null, + selectedItemKeys: [], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + await interact(() => getButton('Select evicted-focus-11.png for preview').focus()); + + // In view after the scroll: the tile as many places into the loaded pages as the focused one was, mid-row. + const viewport = host!.querySelector('[data-part="viewport"]')!; + const deepTile = getButton('Select evicted-focus-71.png for preview'); + await interact(() => { + viewport.scrollTop += deepTile.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + }); + // Scrolled away, page zero leaves the subscribed range. + currentSparseListing = { + itemSlots: new Map(items.slice(60).map((item, index) => [index + 60, item])), + pageStates: new Map([[60, settled]]), + recentItems: [], + total: items.length, + }; + await renderGallery({ ...currentGallery, items: items.slice(60) }); + + expect(document.activeElement).toBe( + deepTile.closest('[data-gallery-section="regular"]')?.querySelector('button[aria-pressed]') + ); + }); + + it('stamps select-all with the absolute page its first item sits on', async () => { + const deepItems = [createItem('image', 'select-all-page-1-a.png'), createItem('image', 'select-all-page-1-b.png')]; + currentSparseListing = { + itemSlots: new Map([ + [60, deepItems[0]!], + [61, deepItems[1]!], + ]), + pageStates: new Map([[60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + await renderGallery( + createGallery({ + items: deepItems, + selectedItemKey: null, + selectedItemKeys: [], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + + registeredCommands.get('gallery.selectAllOnPage')?.(); + + expect(actionMocks.selectItemRange).toHaveBeenCalledExactlyOnceWith( + deepItems.map(toGalleryItemRef), + deepItems[0], + 1 + ); + }); + + it('uses an indexed reveal to request and scroll to only the located page', async () => { + currentSparseListing = { + itemSlots: new Map([[0, createItem('image', 'page-zero.png')]]), + pageStates: new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]), + recentItems: [], + total: 120, + }; + await renderGallery( + createGallery({ + selectedItemKey: 'image:deep.png', + selectedItemKeys: ['image:deep.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }) + ); + + await interact(() => requestReveal('image:deep.png', 70)); + + expect(setVisibleRange).toHaveBeenLastCalledWith({ endIndexExclusive: 120, startIndex: 60 }); + expect(mocks.scrollToIndex).toHaveBeenCalled(); + }); + + it('bounds mounted media and keeps sparse row identity through page load, eviction, and refetch', async () => { + mocks.useRealGridVirtualizer = true; + const items = Array.from({ length: 120 }, (_, index) => createItem('image', `lifecycle-${index}.png`)); + const pageStates = new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]); + const loadedSlots = new Map(items.map((item, index) => [index, item])); + currentSparseListing = { itemSlots: loadedSlots, pageStates, recentItems: [], total: items.length }; + + await renderGallery(createGallery({ items: [], settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } })); + + const viewport = host!.querySelector('[data-part="viewport"]')!; + const gridRows = host!.querySelectorAll('[data-gallery-section="regular"]'); + expect(gridRows.length).toBeGreaterThan(0); + expect(gridRows.length).toBeLessThan(Math.ceil(items.length / 2)); + expect(host!.querySelectorAll('img').length).toBeLessThan(items.length); + + const target = [...host!.querySelectorAll('button[aria-label^="Select lifecycle-"]')].find( + (button) => { + const rect = button.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + return rect.top >= viewportRect.top && rect.bottom <= viewportRect.bottom; + } + ); + expect(target).toBeDefined(); + const targetRow = target!.closest('[data-gallery-section="regular"]')!; + const targetName = target!.querySelector('img')?.getAttribute('alt'); + + currentSparseListing = { itemSlots: new Map(), pageStates, recentItems: [], total: items.length }; + await renderGallery({ ...currentGallery }); + + expect(targetRow.isConnected).toBe(true); + expect(host?.querySelectorAll('[data-gallery-slot-state="empty"]').length).toBeGreaterThan(0); + expect(host?.querySelectorAll('img')).toHaveLength(0); + + currentSparseListing = { itemSlots: loadedSlots, pageStates, recentItems: [], total: items.length }; + await renderGallery({ ...currentGallery }); + + expect(targetRow.isConnected).toBe(true); + expect(targetRow.querySelector(`img[alt="${targetName}"]`)).not.toBeNull(); + + const getAnchor = () => + host!.querySelector(`img[alt="${targetName}"]`)!.closest('button')!; + const anchorOffset = () => getAnchor().getBoundingClientRect().top - viewport.getBoundingClientRect().top; + const columns = () => + getComputedStyle(getAnchor().closest('[data-gallery-section="regular"]')!) + .gridTemplateColumns.trim() + .split(/\s+/).length; + const initialOffset = anchorOffset(); + const initialColumns = columns(); + + host!.style.width = '900px'; + await vi.waitFor(() => expect(columns()).toBeGreaterThan(initialColumns)); + expect(anchorOffset()).toBeCloseTo(initialOffset, 0); + + const resizedOffset = anchorOffset(); + const resizedColumns = columns(); + await renderGallery({ + ...currentGallery, + settings: { ...currentGallery.settings, imageDensityPercent: 75 }, + }); + await vi.waitFor(() => expect(columns()).not.toBe(resizedColumns)); + expect(anchorOffset()).toBeCloseTo(resizedOffset, 0); + + const densityOffset = anchorOffset(); + host!.style.zoom = '1.2'; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + expect(getAnchor().isConnected).toBe(true); + expect(anchorOffset()).toBeCloseTo(densityOffset, 0); + }); + + it('preserves the physical viewport anchor while active sparse pages reconcile conflicting totals', async () => { + mocks.useRealGridVirtualizer = true; + host!.style.cssText = 'height:600px;left:20px;position:fixed;top:20px;width:1200px;'; + const firstPage = Array.from({ length: 60 }, (_, index) => createItem('image', `conflict-${index}.png`)); + const gallery = createGallery({ items: firstPage, settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } }); + const filter = createFilter(gallery); + const firstPageOptions = galleryItemsPageOptions(filter, 0); + const laterPageOptions = galleryItemsPageOptions(filter, 60); + const boardOptions = galleryBoardsOptions(getGalleryListingBoardsQuery(gallery.settings)); + queryClient!.setQueryData(boardOptions.queryKey, [board]); + queryClient!.setQueryData(firstPageOptions.queryKey, { + items: firstPage, + offset: 0, + total: 180, + }); + + const pageCalls = new Map(); + mocks.fetchSparsePage.mockImplementation((_filter, offset) => { + const count = (pageCalls.get(offset) ?? 0) + 1; + pageCalls.set(offset, count); + const total = offset === 60 && count === 1 ? 180 : 181; + + return Promise.resolve({ + items: Array.from({ length: 60 }, (_, index) => createItem('image', `conflict-${offset + index}.png`)), + offset, + total, + }); + }); + + await renderQueryBackedGallery(gallery); + const viewport = host!.querySelector('[data-part="viewport"]')!; + const firstRow = host!.querySelector('[data-gallery-section="regular"]')!; + const firstRowHeight = firstRow.getBoundingClientRect().height + 4; + const initialColumns = getComputedStyle(firstRow).gridTemplateColumns.trim().split(/\s+/).length; + expect(initialColumns).toBeGreaterThan(2); + + await interact(() => { + viewport.scrollTop = 8 * firstRowHeight; + viewport.dispatchEvent(new Event('scroll')); + }); + await vi.waitFor(() => { + expect(mocks.fetchSparsePage.mock.calls.map(([, offset]) => offset)).toContain(60); + expect(host?.querySelector('img[alt="conflict-54.png"]')).not.toBeNull(); + expect(host?.querySelector('img[alt="conflict-60.png"]')).not.toBeNull(); + }); + + const anchor = () => host!.querySelector('img[alt="conflict-54.png"]')!; + const beforeOffset = anchor().getBoundingClientRect().top - viewport.getBoundingClientRect().top; + expect(pageCalls.get(60)).toBe(1); + + await act(async () => { + await queryClient!.invalidateQueries({ exact: true, queryKey: firstPageOptions.queryKey }); + }); + await vi.waitFor(() => { + expect(queryClient!.getQueryData(firstPageOptions.queryKey)?.total).toBe(181); + expect(queryClient!.getQueryData(laterPageOptions.queryKey)?.total).toBe(181); + }); + + expect(pageCalls.get(0)).toBe(2); + expect(pageCalls.get(60)).toBe(2); + expect([...pageCalls.keys()]).toEqual([60, 0]); + expect(anchor().getBoundingClientRect().top - viewport.getBoundingClientRect().top).toBeCloseTo(beforeOffset, 0); + }); + + it('captures a narrow sparse viewport with loaded and page-error slots', async () => { + await page.viewport(800, 680); + const thumbnailUrl = `data:image/svg+xml,${encodeURIComponent('')}`; + const loaded = createItem('image', 'loaded-sparse.png', { fullUrl: thumbnailUrl, thumbnailUrl }); + currentSparseListing = { + itemSlots: new Map([[0, loaded]]), + pageStates: new Map([ + [0, { error: new Error('Page unavailable'), isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: 12, + }; + host!.style.width = '380px'; + + await renderGallery( + createGallery({ items: [loaded], settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } }) + ); + expect(host?.querySelector('img[alt="loaded-sparse.png"]')).not.toBeNull(); + expect(host?.querySelector('[data-gallery-slot-state="error"]')).not.toBeNull(); + const retryButton = host!.querySelector('[data-gallery-slot-state="error"] button')!; + const retryStyle = getComputedStyle(retryButton); + const surface = host!.querySelector('[data-testid="gallery-surface"]')!; + const retryContrast = getContrastRatio( + retryStyle.color, + getComputedStyle(surface).backgroundColor, + Number(retryStyle.opacity) + ); + expect(retryContrast).toBeGreaterThanOrEqual(4.5); + host?.querySelector('[data-testid="context-target"]')?.setAttribute('hidden', ''); + await page.screenshot({ path: '../../../../artifacts/gallery-progress/sparse-grid.png' }); + }); + + it('shows one retry control when a fully populated sparse page refetch fails', async () => { + const items = Array.from({ length: 12 }, (_, index) => createItem('image', `failed-page-${index}.png`)); + const retry = vi.fn(() => Promise.resolve()); + currentSparseListing = { + itemSlots: new Map(items.map((item, index) => [index, item])), + pageStates: new Map([[0, { error: new Error('Refresh failed'), isLoading: false, retry }]]), + recentItems: [], + total: items.length, + }; + + await renderGallery(createGallery({ items, settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } })); + + expect(host?.querySelectorAll('[data-gallery-page-error="0"] button')).toHaveLength(1); + expect( + [...host!.querySelectorAll('button')].filter((button) => button.textContent?.trim() === 'Retry') + ).toHaveLength(1); + expect(host?.querySelectorAll('[data-gallery-slot-state="error"] button')).toHaveLength(0); + + await click(host!.querySelector('[data-gallery-page-error="0"] button')!); + expect(retry).toHaveBeenCalledOnce(); + }); + + it('shows and retries count discovery failure on a cold paginated page after page zero', async () => { + let countAttempts = 0; + const items = Array.from({ length: 5 }, (_, index) => createItem('image', `count-retry-${index}.png`)); + mocks.fetchSparseTotal.mockImplementation(() => { + countAttempts += 1; + return countAttempts === 1 ? Promise.reject(new Error('Count unavailable')) : Promise.resolve(65); + }); + mocks.fetchSparsePage.mockImplementation((_filter, offset) => Promise.resolve({ items, offset, total: 65 })); + + await renderQueryBackedGallery( + createGallery({ + items: [], + page: 1, + settings: { ...DENSE_SETTINGS, paginationMode: 'paginated' }, + }) + ); + + await vi.waitFor(() => { + expect(host?.querySelector('[data-gallery-page-error="0"]')).not.toBeNull(); + }); + const retryButton = host!.querySelector('[data-gallery-page-error="0"] button')!; + expect(retryButton.textContent?.trim()).toBe('Retry'); + expect(mocks.fetchSparsePage).not.toHaveBeenCalled(); + + await click(retryButton); + + await vi.waitFor(() => { + expect(host?.querySelector('img[alt="count-retry-0.png"]')).not.toBeNull(); + }); + expect(countAttempts).toBe(2); + expect(mocks.fetchSparsePage.mock.calls.map(([, offset]) => offset)).toEqual([60]); + }); + + it('repairs offsets after a partial delete and keeps the visible item anchored through a failed page retry', async () => { + const items = Array.from({ length: 96 }, (_, index) => createItem('image', `mutation-item-${index}.png`)); + const gallery = createGallery({ + items, + selectedItemKey: 'image:mutation-item-12.png', + selectedItemKeys: ['image:mutation-item-12.png'], + settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' }, + }); + const filter = createFilter(gallery); + const pageOptions = galleryItemsPageOptions(filter, 0); + const laterPageOptions = galleryItemsPageOptions(filter, 60); + const boardOptions = galleryBoardsOptions(getGalleryListingBoardsQuery(gallery.settings)); + const successfulDelete = { kind: 'image' as const, name: items[3]!.name }; + const failedDelete = { kind: 'image' as const, name: items[8]!.name }; + const repairedItems = items.filter((item) => item.name !== successfulDelete.name); + queryClient!.setQueryData(boardOptions.queryKey, [board]); + queryClient!.setQueryData(pageOptions.queryKey, { + items: items.slice(0, 60), + itemIndices: Array.from({ length: 60 }, (_, index) => index), + offset: 0, + total: items.length, + }); + queryClient!.setQueryData(laterPageOptions.queryKey, { + items: items.slice(60), + itemIndices: Array.from({ length: 36 }, (_, index) => index + 60), + offset: 60, + total: items.length, + }); + mocks.fetchSparsePage + .mockRejectedValueOnce(new Error('Page repair unavailable')) + .mockResolvedValueOnce({ + items: repairedItems.slice(0, 60), + itemIndices: Array.from({ length: 60 }, (_, index) => index), + offset: 0, + total: repairedItems.length, + }) + .mockResolvedValueOnce({ + items: repairedItems.slice(60), + itemIndices: Array.from({ length: 35 }, (_, index) => index + 60), + offset: 60, + total: repairedItems.length, + }); + + await renderQueryBackedGallery(gallery); + + const viewport = host!.querySelector('[data-part="viewport"]')!; + viewport.scrollTop = 1_000; + const initialOptions = mocks.virtualizerOptions.at(-1)!; + await interact(() => + initialOptions.onChange?.({ + getVirtualItems: () => [{ index: 4 }, { index: 5 }], + range: { endIndex: 5, startIndex: 4 }, + }) + ); + const anchor = host!.querySelector( + 'button[aria-label="Select mutation-item-12.png for preview"]' + )!; + const beforeOffset = anchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + let rollback: () => void = () => undefined; + const cachedPage = (offset = 0) => + queryClient!.getQueryData((offset === 0 ? pageOptions : laterPageOptions).queryKey)!; + + expect(cachedPage().itemIndices).toEqual(Array.from({ length: 60 }, (_, index) => index)); + expect(cachedPage(60).itemIndices).toEqual(Array.from({ length: 36 }, (_, index) => index + 60)); + + await interact(() => { + rollback = patchGalleryItemCaches(queryClient!, { + kind: 'delete', + result: { failed: [], succeeded: [successfulDelete, failedDelete] }, + }); + }); + expect(queryClient!.getQueryData(pageOptions.queryKey)?.total).toBe(94); + expect(cachedPage().itemIndices).toEqual([ + 0, 1, 2, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + ]); + expect(cachedPage(60).itemIndices).toEqual(Array.from({ length: 36 }, (_, index) => index + 60)); + expect(host?.querySelector('img[alt="mutation-item-3.png"]')).toBeNull(); + expect(host?.querySelector('img[alt="mutation-item-8.png"]')).toBeNull(); + + // Match the production partial-failure path: roll the optimistic batch back, then apply only confirmed refs. + await interact(() => { + rollback(); + expect(cachedPage().itemIndices).toEqual(Array.from({ length: 60 }, (_, index) => index)); + expect(cachedPage(60).itemIndices).toEqual(Array.from({ length: 36 }, (_, index) => index + 60)); + patchGalleryItemCaches(queryClient!, { + kind: 'delete', + result: { failed: [failedDelete], succeeded: [successfulDelete] }, + }); + }); + expect(queryClient!.getQueryData(pageOptions.queryKey)?.total).toBe(95); + expect(cachedPage().itemIndices).toEqual([ + 0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + ]); + expect(cachedPage(60).itemIndices).toEqual(Array.from({ length: 36 }, (_, index) => index + 60)); + expect(host?.querySelector('img[alt="mutation-item-3.png"]')).toBeNull(); + expect(host?.querySelector('img[alt="mutation-item-8.png"]')).not.toBeNull(); + + await act(async () => { + await invalidateGalleryItems(queryClient!); + }); + expect(mocks.fetchSparsePage).toHaveBeenCalledTimes(1); + expect(mocks.fetchSparsePage.mock.calls.map(([, offset]) => offset)).toEqual([0]); + await vi.waitFor(() => expect(host?.querySelectorAll('[data-gallery-slot-state="error"] button')).toHaveLength(1)); + const retryOffset = + host! + .querySelector('button[aria-label="Select mutation-item-12.png for preview"]')! + .getBoundingClientRect().top - viewport.getBoundingClientRect().top; + expect(Math.abs(retryOffset - beforeOffset)).toBeLessThan(1); + + await click(host!.querySelector('[data-gallery-slot-state="error"] button')!); + await vi.waitFor(() => { + expect(mocks.fetchSparsePage).toHaveBeenCalledTimes(2); + expect(host?.querySelector('[data-gallery-slot-state="error"]')).toBeNull(); + }); + + const repairedAnchor = host!.querySelector( + 'button[aria-label="Select mutation-item-12.png for preview"]' + )!; + const repairedOffset = repairedAnchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + expect(Math.abs(repairedOffset - beforeOffset)).toBeLessThan(1); + expect(viewport.scrollTop).toBeLessThan(1_000); + expect(host?.querySelector('img[alt="mutation-item-8.png"]')).not.toBeNull(); + expect(queryClient!.getQueryData(pageOptions.queryKey)?.total).toBe(95); + expect(cachedPage().itemIndices).toEqual(Array.from({ length: 60 }, (_, index) => index)); + expect(cachedPage(60).itemIndices).toEqual(Array.from({ length: 36 }, (_, index) => index + 60)); + await act(async () => { + await queryClient!.fetchQuery(laterPageOptions); + }); + expect(mocks.fetchSparsePage.mock.calls.map(([, offset]) => offset)).toEqual([0, 0, 60]); + expect(cachedPage(60).items.map((item) => item.name)).toEqual(repairedItems.slice(60).map((item) => item.name)); + expect(cachedPage(60).itemIndices).toEqual(Array.from({ length: 35 }, (_, index) => index + 60)); + }); + + it('preserves a loaded item viewport anchor when the responsive column count changes', async () => { + const items = Array.from({ length: 120 }, (_, index) => createItem('image', `anchor-${index}.png`)); + currentSparseListing = { + itemSlots: new Map(items.map((item, index) => [index, item])), + pageStates: new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]), + recentItems: [], + total: items.length, + }; + await renderGallery( + createGallery({ items: [items[0]!], settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } }) + ); + + const viewport = host!.querySelector('[data-part="viewport"]')!; + viewport.scrollTop = 1_000; + const initialOptions = mocks.virtualizerOptions.at(-1)!; + await interact(() => + initialOptions.onChange?.({ + getVirtualItems: () => [{ index: 4 }, { index: 5 }], + range: { endIndex: 5, startIndex: 4 }, + }) + ); + const anchor = host!.querySelector('button[aria-label="Select anchor-12.png for preview"]')!; + const beforeOffset = anchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + + host!.style.width = '900px'; + await vi.waitFor(() => + expect(mocks.virtualizerOptions.at(-1)?.estimateSize(0)).not.toBe(initialOptions.estimateSize(0)) + ); + + const resizedAnchor = host!.querySelector( + 'button[aria-label="Select anchor-12.png for preview"]' + )!; + const afterOffset = resizedAnchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + expect(afterOffset).toBeCloseTo(beforeOffset, 0); + }); + + it('preserves a sparse viewport anchor when page updates move its absolute position', async () => { + const items = Array.from({ length: 120 }, (_, index) => createItem('image', `moving-anchor-${index}.png`)); + const pageStates = new Map([ + [0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + [60, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }], + ]); + currentSparseListing = { + itemSlots: new Map(items.map((item, index) => [index, item])), + pageStates, + recentItems: [], + total: items.length, + }; + await renderGallery(createGallery({ items, settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } })); + + const viewport = host!.querySelector('[data-part="viewport"]')!; + viewport.scrollTop = 1_000; + const initialOptions = mocks.virtualizerOptions.at(-1)!; + await interact(() => + initialOptions.onChange?.({ + getVirtualItems: () => [{ index: 4 }, { index: 5 }], + range: { endIndex: 5, startIndex: 4 }, + }) + ); + const anchor = host!.querySelector( + 'button[aria-label="Select moving-anchor-12.png for preview"]' + )!; + const beforeOffset = anchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + const beforeScrollTop = viewport.scrollTop; + const inserted = Array.from({ length: 3 }, (_, index) => createItem('image', `inserted-${index}.png`)); + const updatedItems = [...items.slice(0, 12), ...inserted, ...items.slice(12)]; + currentSparseListing = { + itemSlots: new Map(updatedItems.map((item, index) => [index, item])), + pageStates, + recentItems: [], + total: updatedItems.length, + }; + + await renderGallery({ ...currentGallery, items: updatedItems }); + + const movedAnchor = host!.querySelector( + 'button[aria-label="Select moving-anchor-12.png for preview"]' + )!; + const afterOffset = movedAnchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + expect(afterOffset).toBeCloseTo(beforeOffset, 0); + expect(viewport.scrollTop).toBeGreaterThan(beforeScrollTop); + }); + + it('chooses the nearest loaded item when a sparse viewport anchor is deleted', async () => { + const items = Array.from({ length: 13 }, (_, index) => createItem('image', `deleted-anchor-${index}.png`)); + const pageStates = new Map([[0, { error: null, isLoading: false, retry: vi.fn(() => Promise.resolve()) }]]); + currentSparseListing = { + itemSlots: new Map(items.map((item, index) => [index, item])), + pageStates, + recentItems: [], + total: items.length, + }; + await renderGallery(createGallery({ items, settings: { ...DENSE_SETTINGS, paginationMode: 'infinite' } })); + + const viewport = host!.querySelector('[data-part="viewport"]')!; + viewport.scrollTop = 1_000; + const initialOptions = mocks.virtualizerOptions.at(-1)!; + await interact(() => + initialOptions.onChange?.({ getVirtualItems: () => [{ index: 4 }], range: { endIndex: 4, startIndex: 4 } }) + ); + const anchor = host!.querySelector( + 'button[aria-label="Select deleted-anchor-12.png for preview"]' + )!; + const beforeOffset = anchor.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + const remainingItems = items.slice(0, -1); + currentSparseListing = { + itemSlots: new Map(remainingItems.map((item, index) => [index, item])), + pageStates, + recentItems: [], + total: remainingItems.length, + }; + + await renderGallery({ ...currentGallery, items: remainingItems }); + + const fallback = host!.querySelector( + 'button[aria-label="Select deleted-anchor-11.png for preview"]' + )!; + const afterOffset = fallback.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + expect(Math.abs(afterOffset - beforeOffset)).toBeLessThan(1); + expect(viewport.scrollTop).toBeLessThan(1_000); + }); + it('keeps external-store option callbacks stable across equivalent renders', async () => { const gallery = createGallery({ items: [createItem('video', 'clip.mp4')] }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx index 72805f10b0b..584933ad960 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx @@ -1,4 +1,4 @@ -import { Box, chakra, Flex, HStack, Icon, ScrollArea, Spinner, Stack, Text } from '@chakra-ui/react'; +import { Box, chakra, Flex, HStack, Icon, ScrollArea, Spinner, Stack, Text, VisuallyHidden } from '@chakra-ui/react'; import { getGalleryBoardLabel } from '@features/gallery/core/boardLabels'; import { toGalleryItemKey, type GalleryItem, type GalleryItemKey } from '@features/gallery/core/items'; import { @@ -9,6 +9,7 @@ import { type GalleryNavigationEntry, type GalleryRevealRequest, } from '@features/gallery/core/selection'; +import { gallerySemanticReferenceKey } from '@features/gallery/core/semanticImageQuery'; import { isDateBoardId } from '@features/gallery/data/backend'; import { GALLERY_PAGE_SIZE, @@ -33,11 +34,14 @@ import { type ReactNode, } from 'react'; import { flushSync } from 'react-dom'; -import { defaultRangeExtractor, useVirtualizer, type Range } from 'react-hook-tanstack-virtual'; +import { defaultRangeExtractor, useVirtualizer } from 'react-hook-tanstack-virtual'; import { useTranslation } from 'react-i18next'; +import type { GallerySparsePageState } from './useGalleryData'; + import { buildGalleryGridRows, + buildSparseGalleryNavigationEntries, chunkGalleryCellsIntoRows, GALLERY_GRID_GAP_PX, GALLERY_PINNED_FOOTER_PX, @@ -45,6 +49,10 @@ import { getGalleryCellSizePx, getGalleryColumnCount, getGalleryGridRowIndexForItemKey, + getGallerySparseRowIndexForItemKey, + getGallerySparseRowKey, + getGallerySparseSelectionPages, + getGallerySparseSlotKey, getGalleryPinnedHeightPx, getGalleryProgressLayout, getGalleryStarredLayout, @@ -56,10 +64,16 @@ import { getGallerySelectedImageQuery, isGallerySelectionInScope } from './galle import { GALLERY_TAB_STOP_SELECTOR, GalleryThumbnailCell } from './GalleryThumbnail'; import { useGalleryUi } from './GalleryUiContext'; import { useGalleryWidget, type GalleryStarredStrip } from './GalleryWidgetContext'; -import { useGalleryGridHotkeys } from './useGalleryGridHotkeys'; +import { + useGalleryGridHotkeys, + type GalleryNavigationMode, + type GalleryUnloadedNavigationRequest, +} from './useGalleryGridHotkeys'; import { useGalleryGridSelection } from './useGalleryGridSelection'; import { useGalleryUploadInput } from './useGalleryUploadInput'; +type VirtualRange = Parameters[0]; + /** * Seeds the measured width per region so remounting the gallery in a placement * it has already been shown in does not paint one frame of fallback-sized @@ -67,12 +81,125 @@ import { useGalleryUploadInput } from './useGalleryUploadInput'; */ const viewportWidthCache = new Map(); const STARRED_TRIGGER_HOVER_STYLES = { color: 'fg' } as const; +const EMPTY_GALLERY_ITEMS: GalleryItem[] = []; // Module-scoped so a grid remount cannot replay an already-followed reveal. let lastPageFollowedRevealToken = 0; const dragEventContainsFiles = (event: DragEvent): boolean => Array.from(event.dataTransfer.types).includes('Files'); +const GalleryPageError = ({ + onFocusLost, + compact = false, + pageOffset, + pageState, +}: { + onFocusLost: () => void; + compact?: boolean; + pageOffset: number; + pageState: GallerySparsePageState; +}) => { + const { t } = useTranslation(); + const isMoreError = pageOffset > 0; + const read = useMemo( + () => ({ + isRetrying: pageState.isLoading, + retry: async () => { + await pageState.retry(); + }, + }), + [pageState] + ); + const message = t(isMoreError ? 'widgets.gallery.listingLoadMoreFailed' : 'widgets.gallery.listingRefreshFailed'); + + if (compact) { + return ( + <> + + {message} {pageState.error?.message} + + + + ); + } + + return ( + + + {message} + + + + ); +}; + +const GallerySparseSlot = ({ + cellSizePx, + onFocusLost, + pageOffset, + pageState, + showPageStatus, +}: { + cellSizePx: number; + onFocusLost: () => void; + pageOffset: number; + pageState: GallerySparsePageState | undefined; + showPageStatus: boolean; +}) => { + const { t } = useTranslation(); + const error = pageState?.error; + const state = error && showPageStatus ? 'error' : pageState?.isLoading && showPageStatus ? 'loading' : 'empty'; + + return ( + 0 ? 'widgets.gallery.listingLoadMoreFailed' : 'widgets.gallery.listingRefreshFailed') + : undefined + } + > + {state === 'loading' ? ( + + + ) : state === 'error' && pageState ? ( + + ) : null} + + ); +}; + /** Show all appears only when starred items exceed the strip and activates the starred-only listing. */ const GalleryStarredSectionHeader = ({ isOpen, @@ -237,10 +364,12 @@ interface FocusedTile { filter: GalleryItemsFilter; index: number; key: GalleryItemKey; + /** The sparse slot it showed, whose next occupant inherits the Tab stop; positions elsewhere shift with eviction. */ + slot?: number; } /** The rows in view plus the one keyboard focus needs wherever the grid scrolls; -1 keeps nothing. */ -const extractRangeKeeping = (range: Range, keptRow: number): number[] => { +const extractRangeKeeping = (range: VirtualRange, keptRow: number): number[] => { const indexes = defaultRangeExtractor(range); return keptRow < 0 || keptRow >= range.count || indexes.includes(keptRow) @@ -257,11 +386,11 @@ const getTileItemKey = (element: Element | null): GalleryItemKey | null => /** The tile that shows a navigation entry: a thumbnail, or an in-progress session. */ const findEntryTile = (viewport: HTMLElement, entry: GalleryNavigationEntry): HTMLElement | null => - viewport.querySelector( - entry.kind === 'session' - ? `[data-gallery-session-id="${CSS.escape(entry.id)}"]` - : `[data-gallery-item-key="${CSS.escape(toGalleryItemKey(entry.item))}"]` - ); + entry.kind === 'session' + ? viewport.querySelector(`[data-gallery-session-id="${CSS.escape(entry.id)}"]`) + : entry.kind === 'item' + ? viewport.querySelector(`[data-gallery-item-key="${CSS.escape(toGalleryItemKey(entry.item))}"]`) + : null; /** Prefers a visible tile; an empty grid offers whatever else it shows (its upload target, the strip header). */ const focusVisibleGridContent = (viewport: HTMLElement | null, edge: 'first' | 'last') => { @@ -273,8 +402,19 @@ const focusVisibleGridContent = (viewport: HTMLElement | null, edge: 'first' | ' /** Measure viewport width for columns so both layouts share the same grid. */ export const GalleryImageGrid = () => { const { t } = useTranslation(); - const { actions, filter, gallery, isWindowTruncated, itemActions, listing, region, starredStrip } = - useGalleryWidget(); + const { + actions, + filter, + gallery, + isWindowTruncated, + itemActions, + listing, + pinRevealIndex, + region, + setVisibleRange, + sparseListing, + starredStrip, + } = useGalleryWidget(); const { gallery: galleryCommands, galleryValues, @@ -299,6 +439,20 @@ export const GalleryImageGrid = () => { thumbnailFit, } = gallery.settings; const isStarredOpen = !starredSectionCollapsed; + const usesSparseListing = sparseListing !== undefined; + const isSparsePaginated = usesSparseListing && paginationMode === 'paginated'; + const sparsePageOffset = isSparsePaginated ? gallery.page * GALLERY_PAGE_SIZE : 0; + const sparseSelectionPages = useMemo( + () => + sparseListing + ? getGallerySparseSelectionPages({ itemSlots: sparseListing.itemSlots, pageOffset: sparsePageOffset }) + : new Map(), + [sparseListing, sparsePageOffset] + ); + const getSelectionPage = useCallback( + (item: GalleryItem) => sparseSelectionPages.get(toGalleryItemKey(item)), + [sparseSelectionPages] + ); const { actionSelectionRefs, @@ -310,11 +464,22 @@ export const GalleryImageGrid = () => { loadedItems, selectedItemKeys, selectItemRange, + shouldStarSelection, syncRangeInteractionContext, toggleItem, - } = useGalleryGridSelection(); + } = useGalleryGridSelection({ getSelectionPage }); const columnCount = getGalleryColumnCount({ imageDensityPercent, widthPx: viewportWidth }); + const sparseRecentItems = usesSparseListing ? sparseListing.recentItems : EMPTY_GALLERY_ITEMS; + const sparseRecentRowCount = Math.ceil(sparseRecentItems.length / columnCount); + const sparseRecentAtTop = usesSparseListing && !isSparsePaginated && gallery.settings.imageOrderDir === 'DESC'; + const sparseFilterIdentity = JSON.stringify(filter); + const leadingRecentRows = sparseRecentAtTop ? sparseRecentRowCount : 0; + const sparseBackendItemCount = usesSparseListing + ? isSparsePaginated + ? Math.min(GALLERY_PAGE_SIZE, Math.max(0, (sparseListing.total ?? 0) - sparsePageOffset)) + : (sparseListing.total ?? 0) + : 0; const isFollowingLive = followedProgressSessionId !== null; const isComparisonActive = gallery.isComparisonActive && !isFollowingLive; const selectedBoard = gallery.boards.find((board) => board.id === gallery.selectedBoardId); @@ -323,7 +488,9 @@ export const GalleryImageGrid = () => { : t('widgets.gallery.selectedBoardFallback'); // The listing is unstarred-only, so a board whose items are all starred // still has the strip to show. - const isEmpty = gallery.items.length === 0 && starredStrip.items.length === 0; + const isEmpty = + (usesSparseListing ? sparseBackendItemCount === 0 && sparseRecentItems.length === 0 : gallery.items.length === 0) && + starredStrip.items.length === 0; // A failed strip with no cells to show (none loaded, or an empty earlier result) reports in the header's place. const isStarredFailedEmpty = (starredStrip.state.status === 'error' || starredStrip.state.status === 'stale-error') && @@ -346,13 +513,21 @@ export const GalleryImageGrid = () => { // shown rows, which the strip holds, or — as in Preview — one beyond the strip's bound, which the view names no // visible key for and only the persisted selection holds (the listing is unstarred). Either way the arrows step // from it rather than from the first tile. The persisted selection counts only when made in this listing: one - // left over from another board, search or filter would put a phantom entry at the end of this strip. + // left over from another board, search or filter would put a phantom entry at the end of this strip. Where the + // strip does not apply (the starred-only listing, a ranking, an anchored window) there is no strip to hold it. + const isStripApplicable = + !gallery.starredOnly && gallery.semanticImageQuery === null && gallery.anchoredWindowPage === 0; const hiddenStripSelection = useMemo((): GalleryItem | null => { const selectedItem = getSelectedGalleryItemFromValues(galleryValues); const selectedKey = gallery.selectedItemKey ?? (selectedItem ? toGalleryItemKey(selectedItem) : null); const isSelected = (item: GalleryItem) => toGalleryItemKey(item) === selectedKey; - if (selectedKey === null || shownStripItems.some(isSelected) || gallery.items.some(isSelected)) { + if ( + !isStripApplicable || + selectedKey === null || + shownStripItems.some(isSelected) || + gallery.items.some(isSelected) + ) { return null; } @@ -364,7 +539,7 @@ export const GalleryImageGrid = () => { ? selectedItem : null) ); - }, [gallery, galleryValues, shownStripItems, starredStrip.items]); + }, [gallery, galleryValues, isStripApplicable, shownStripItems, starredStrip.items]); const navigationSections = useMemo((): GalleryNavigationEntry[][] => { const stripEntries: GalleryNavigationEntry[] = shownStripItems.map((item) => ({ item, kind: 'item' })); @@ -372,6 +547,26 @@ export const GalleryImageGrid = () => { stripEntries.push({ item: hiddenStripSelection, kind: 'item' }); } + const loadingPageOffsets = [...(sparseListing?.pageStates ?? [])] + .filter(([, pageState]) => pageState.isLoading) + .map(([offset]) => offset); + // Paginated slots are page-local and that mode subscribes one page, which is offset 0 here. + const pendingPageOffsets = new Set( + isSparsePaginated ? (loadingPageOffsets.length > 0 ? [0] : []) : loadingPageOffsets + ); + const listingEntries = + usesSparseListing && sparseListing + ? buildSparseGalleryNavigationEntries({ + columnCount, + includeUnloadedBoundaries: !isSparsePaginated, + itemSlots: sparseListing.itemSlots, + pageOffsets: isSparsePaginated ? [0] : [...sparseListing.pageStates.keys()], + pendingPageOffsets, + total: isSparsePaginated ? sparseBackendItemCount : sparseListing.total, + }) + : gallery.items.map((item) => ({ item, kind: 'item' }) as const); + const recentEntries = sparseRecentItems.map((item) => ({ item, kind: 'item' }) as const); + return [ stripEntries, isProgressOpen @@ -381,40 +576,126 @@ export const GalleryImageGrid = () => { navigable: session.state === 'running', })) : [], - gallery.items.map((item) => ({ item, kind: 'item' })), + ...(sparseRecentAtTop ? [recentEntries, listingEntries] : [listingEntries, recentEntries]), ]; - }, [gallery.items, hiddenStripSelection, isProgressOpen, progressSessions, shownStripItems]); - - // Thumbnails in visual order, strip first. They share one thumbnail Tab stop: the tile focus was last on, else the - // selection, else the tile that took the focused one's place in the same scope (after a deletion, the neighbour - // selected next), else the first. Progress tiles, disclosures, the upload zone and retries keep their own stops. - const tileKeys = useMemo( - () => [...shownStripItems, ...gallery.items].map(toGalleryItemKey), - [gallery.items, shownStripItems] + }, [ + gallery.items, + columnCount, + isProgressOpen, + hiddenStripSelection, + progressSessions, + sparseListing, + isSparsePaginated, + sparseBackendItemCount, + sparseRecentAtTop, + sparseRecentItems, + shownStripItems, + usesSparseListing, + ]); + // What a step into an unloaded page started from. The persisted primary, unlike the visible key, survives its page + // leaving the subscribed range while the target page loads. + const navigationOriginKey = + followedProgressSessionId !== null + ? getGallerySessionNavigationKey(followedProgressSessionId) + : gallery.primarySelectedItemKey; + // Thumbnails in visual order share one Tab stop. Sparse slots must retain their real row positions even when pages + // are unloaded, and recent results sit at the order-dependent end of that listing. + const sparseSlotEntries = useMemo( + () => [...(sparseListing?.itemSlots.entries() ?? [])].sort(([left], [right]) => left - right), + [sparseListing?.itemSlots] ); + const tileKeys = useMemo(() => { + const items = usesSparseListing + ? [ + ...shownStripItems, + ...(sparseRecentAtTop ? sparseRecentItems : []), + ...sparseSlotEntries.map(([, item]) => item), + ...(!sparseRecentAtTop ? sparseRecentItems : []), + ] + : [...shownStripItems, ...gallery.items]; + + return items.map(toGalleryItemKey); + }, [gallery.items, shownStripItems, sparseRecentAtTop, sparseRecentItems, sparseSlotEntries, usesSparseListing]); const [focusedTile, setFocusedTile] = useState(null); const selectedTileIndex = gallery.selectedItemKey === null ? -1 : tileKeys.indexOf(gallery.selectedItemKey); const focusedTileIndex = focusedTile === null ? -1 : tileKeys.indexOf(focusedTile.key); + const focusedSlotItem = focusedTile?.slot === undefined ? undefined : sparseListing?.itemSlots.get(focusedTile.slot); const tabStopIndex = focusedTileIndex >= 0 ? focusedTileIndex : selectedTileIndex >= 0 ? selectedTileIndex : focusedTile?.filter === filter - ? Math.max(0, Math.min(focusedTile.index, tileKeys.length - 1)) + ? focusedTile.slot === undefined + ? Math.max(0, Math.min(focusedTile.index, tileKeys.length - 1)) + : Math.max(0, focusedSlotItem ? tileKeys.indexOf(toGalleryItemKey(focusedSlotItem)) : 0) : 0; const tabStopKey = tileKeys[tabStopIndex] ?? null; - const tabStopRow = - tabStopIndex < shownStripItems.length ? -1 : Math.floor((tabStopIndex - shownStripItems.length) / columnCount); + const tabStopItemKey = tabStopKey; + const tabStopStripIndex = + tabStopItemKey === null ? -1 : shownStripItems.findIndex((item) => toGalleryItemKey(item) === tabStopItemKey); + const tabStopRecentIndex = + usesSparseListing && tabStopItemKey !== null + ? sparseRecentItems.findIndex((item) => toGalleryItemKey(item) === tabStopItemKey) + : -1; + const tabStopSparseIndex = + usesSparseListing && tabStopItemKey !== null + ? (sparseSlotEntries.find(([, item]) => toGalleryItemKey(item) === tabStopItemKey)?.[0] ?? -1) + : -1; + const sparseBackendRows = Math.ceil(sparseBackendItemCount / columnCount); + const tabStopRow = usesSparseListing + ? tabStopStripIndex >= 0 + ? -1 + : tabStopRecentIndex >= 0 + ? (sparseRecentAtTop ? 0 : sparseBackendRows) + Math.floor(tabStopRecentIndex / columnCount) + : tabStopSparseIndex >= 0 + ? leadingRecentRows + Math.floor(tabStopSparseIndex / columnCount) + : -1 + : tabStopIndex < shownStripItems.length + ? -1 + : Math.floor((tabStopIndex - shownStripItems.length) / columnCount); // Scrolling never unmounts the Tab stop, which is also the focused tile while the grid holds focus: focus would // fall to the document body, and Tab would skip the grid. - const rangeExtractor = useCallback((range: Range) => extractRangeKeeping(range, tabStopRow), [tabStopRow]); + const rangeExtractor = useCallback((range: VirtualRange) => extractRangeKeeping(range, tabStopRow), [tabStopRow]); - const rowCount = rows.length; + const backendRowCount = usesSparseListing ? Math.ceil(sparseBackendItemCount / columnCount) : rows.length; + const rowCount = usesSparseListing ? backendRowCount + sparseRecentRowCount : rows.length; const cellSizePx = getGalleryCellSizePx({ columnCount, widthPx: viewportWidth }); const rowHeightPx = cellSizePx + GALLERY_GRID_GAP_PX; const estimateRowSize = useCallback(() => rowHeightPx, [rowHeightPx]); - const getRowKey = useCallback((index: number) => rows[index]?.key ?? index, [rows]); + const getRowKey = useCallback( + (index: number) => { + if (!usesSparseListing || !sparseListing) { + return rows[index]?.key ?? index; + } + + const isRecentRow = sparseRecentAtTop ? index < sparseRecentRowCount : index >= backendRowCount; + + if (isRecentRow) { + const recentRow = sparseRecentAtTop ? index : index - backendRowCount; + const firstRecentItem = sparseRecentItems[recentRow * columnCount]; + + return `recent-row:${firstRecentItem ? toGalleryItemKey(firstRecentItem) : recentRow}`; + } + + const backendRow = index - leadingRecentRows; + const absoluteBackendRow = Math.floor(sparsePageOffset / columnCount) + backendRow; + + return getGallerySparseRowKey(absoluteBackendRow); + }, + [ + backendRowCount, + columnCount, + leadingRecentRows, + rows, + sparsePageOffset, + sparseListing, + sparseRecentAtTop, + sparseRecentItems, + sparseRecentRowCount, + usesSparseListing, + ] + ); const getScrollElement = useCallback(() => viewportRef.current, []); const progressLayout = getGalleryProgressLayout({ @@ -432,15 +713,241 @@ export const GalleryImageGrid = () => { tileSize: cellSizePx, }); const pinnedHeight = getGalleryPinnedHeightPx(progressLayout.height, starredLayout.height); + const currentSparseGeometry = { columnCount, leadingRecentRows, pinnedHeight, rowHeightPx }; + const lastMeasuredSparseGeometryRef = useRef(currentSparseGeometry); + const sparseScrollAnchorRef = useRef<{ + absoluteIndex: number; + itemKey: GalleryItemKey; + viewportOffsetPx: number; + } | null>(null); + const sparsePositionSnapshotRef = useRef<{ + filterIdentity: string; + itemKeys: ReadonlyMap; + pageOffset: number; + total: number | null; + } | null>(null); + const handleVirtualizerChange = useCallback( + (instance: { + getVirtualItems: () => readonly { index: number }[]; + range?: { endIndex: number; startIndex: number } | null; + }) => { + if (!usesSparseListing || !sparseListing) { + return; + } + + // The range extractor also retains the roving Tab stop, which may be far outside the viewport. Do not let that + // accessibility row expand the sparse Query subscription or become the scroll anchor. + const visibleItems = instance + .getVirtualItems() + .filter( + ({ index }) => + instance.range === null || + instance.range === undefined || + (index >= instance.range.startIndex && index <= instance.range.endIndex) + ); + const viewport = viewportRef.current; + const previousGeometry = lastMeasuredSparseGeometryRef.current; + const canCaptureAnchor = + previousGeometry.columnCount === columnCount && + previousGeometry.leadingRecentRows === leadingRecentRows && + previousGeometry.pinnedHeight === pinnedHeight && + previousGeometry.rowHeightPx === rowHeightPx; + + if (viewport && canCaptureAnchor) { + for (const visibleRow of visibleItems) { + const rowTop = pinnedHeight + visibleRow.index * rowHeightPx; + + if (rowTop + rowHeightPx <= viewport.scrollTop) { + continue; + } + + const isRecentRow = sparseRecentAtTop + ? visibleRow.index < sparseRecentRowCount + : visibleRow.index >= backendRowCount; + + if (isRecentRow) { + continue; + } + + const backendRow = visibleRow.index - leadingRecentRows; + const firstLocalIndex = backendRow * columnCount; + let capturedAnchor = false; + + for (let column = 0; column < columnCount; column += 1) { + const localIndex = firstLocalIndex + column; + const item = sparseListing.itemSlots.get(localIndex); + + if (item) { + sparseScrollAnchorRef.current = { + absoluteIndex: sparsePageOffset + localIndex, + itemKey: toGalleryItemKey(item), + viewportOffsetPx: rowTop - viewport.scrollTop, + }; + capturedAnchor = true; + break; + } + } + + if (capturedAnchor) { + break; + } + } + } + + if (isSparsePaginated || !setVisibleRange) { + return; + } + + const firstRow = visibleItems[0]?.index; + const lastRow = visibleItems[visibleItems.length - 1]?.index; + + if (firstRow === undefined || lastRow === undefined) { + return; + } + + const firstBackendRow = Math.min(backendRowCount, Math.max(0, firstRow - leadingRecentRows)); + const afterLastBackendRow = Math.min(backendRowCount, Math.max(0, lastRow - leadingRecentRows + 1)); + + if (afterLastBackendRow <= firstBackendRow) { + // Descending recents precede backend slot zero. Before count discovery, ascending recents keep page zero + // active; afterward the backend tail page reconciles ascending recent overlays with the authoritative list. + if (sparseRecentAtTop || sparseListing.total === null) { + setVisibleRange({ endIndexExclusive: GALLERY_PAGE_SIZE, startIndex: 0 }); + } else { + const total = sparseListing.total ?? 0; + const tailPageOffset = total > 0 ? Math.floor((total - 1) / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE : total; + setVisibleRange({ endIndexExclusive: total, startIndex: tailPageOffset }); + } + return; + } + + const startIndex = Math.min(sparseListing.total ?? 0, firstBackendRow * columnCount); + const endIndexExclusive = Math.min(sparseListing.total ?? 0, afterLastBackendRow * columnCount); + + setVisibleRange({ endIndexExclusive, startIndex }); + }, + [ + backendRowCount, + columnCount, + leadingRecentRows, + isSparsePaginated, + pinnedHeight, + rowHeightPx, + setVisibleRange, + sparseListing, + sparsePageOffset, + sparseRecentAtTop, + sparseRecentRowCount, + usesSparseListing, + ] + ); const virtualizer = useVirtualizer({ count: rowCount, scrollMargin: pinnedHeight, estimateSize: estimateRowSize, getItemKey: getRowKey, getScrollElement, + onChange: handleVirtualizerChange, overscan: GRID_OVERSCAN_ROWS, rangeExtractor, }); + const sparseNavigationGenerationRef = useRef(0); + const pendingSparseNavigationRef = useRef<{ + anchorKey: GalleryItemKey | null; + filterIdentity: string; + index: number; + mode: GalleryNavigationMode; + navigationGeneration: number; + onResolved: (itemKey: GalleryItemKey) => void; + originItemKey: string | null; + originFocusKey: string | null; + shouldRestoreFocus: boolean; + } | null>(null); + const pendingSparseFocusRef = useRef<{ + filterIdentity: string; + itemKey: GalleryItemKey; + originFocusKey: string; + } | null>(null); + const cancelPendingSparseNavigation = useCallback(() => { + sparseNavigationGenerationRef.current += 1; + pendingSparseNavigationRef.current = null; + pendingSparseFocusRef.current = null; + }, []); + /** + * A `verified` index comes from a reveal's locator and may exceed a total counted before another client added + * items; its page is pinned so its fresher total reconciles the listing. `keepIndex` names a slot whose page stays + * subscribed alongside when it neighbors the target page, as an arrow step's origin must until the step lands. + */ + const requestSparseAbsoluteIndex = useCallback( + (absoluteIndex: number, { keepIndex, verified = false }: { keepIndex?: number; verified?: boolean } = {}) => { + if (!sparseListing || isSparsePaginated || !setVisibleRange) { + return; + } + + if (absoluteIndex < 0 || (!verified && sparseListing.total !== null && absoluteIndex >= sparseListing.total)) { + return; + } + + if (verified) { + pinRevealIndex?.(absoluteIndex); + } + + const pageOffset = Math.floor(absoluteIndex / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const keepPageOffset = + keepIndex === undefined ? pageOffset : Math.floor(keepIndex / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + // Only a neighboring origin stays: an origin far off, such as a strip item also loaded deep in the listing, + // would subscribe every page between the two. + const keptPageOffset = Math.abs(keepPageOffset - pageOffset) <= GALLERY_PAGE_SIZE ? keepPageOffset : pageOffset; + + // A page already subscribed keeps the range around it; narrowing to it would drop the cursor's page. + if (!sparseListing.pageStates.has(pageOffset)) { + setVisibleRange({ + endIndexExclusive: Math.min( + sparseListing.total ?? Number.POSITIVE_INFINITY, + Math.max(pageOffset, keptPageOffset) + GALLERY_PAGE_SIZE + ), + startIndex: Math.min(pageOffset, keptPageOffset), + }); + } + virtualizer.scrollToIndex(leadingRecentRows + Math.floor(absoluteIndex / columnCount)); + }, + [columnCount, isSparsePaginated, leadingRecentRows, pinRevealIndex, setVisibleRange, sparseListing, virtualizer] + ); + const handleNavigateToUnloadedSlot = useCallback( + (index: number, request: GalleryUnloadedNavigationRequest) => { + const viewport = viewportRef.current; + const shouldRestoreFocus = Boolean(viewport?.contains(document.activeElement)); + const active = document.activeElement; + const activeSessionId = + shouldRestoreFocus && active instanceof HTMLElement + ? active.closest('[data-gallery-session-id]')?.getAttribute('data-gallery-session-id') + : null; + + const originFocusKey = shouldRestoreFocus + ? activeSessionId + ? getGallerySessionNavigationKey(activeSessionId) + : getTileItemKey(active) + : null; + // The step starts from the focused tile, else the selection; a listing slot's page stays loaded under it. + const stepOriginKey = originFocusKey ?? navigationOriginKey; + const originSlot = sparseSlotEntries.find(([, item]) => toGalleryItemKey(item) === stepOriginKey)?.[0]; + + pendingSparseFocusRef.current = null; + pendingSparseNavigationRef.current = { + anchorKey: request.anchorKey, + filterIdentity: sparseFilterIdentity, + index, + mode: request.mode, + navigationGeneration: sparseNavigationGenerationRef.current, + onResolved: request.onResolved, + originItemKey: navigationOriginKey, + originFocusKey, + shouldRestoreFocus, + }; + requestSparseAbsoluteIndex(index, { keepIndex: originSlot }); + }, + [navigationOriginKey, requestSparseAbsoluteIndex, sparseFilterIdentity, sparseSlotEntries] + ); const measureVirtualizer = useEffectEvent(() => { virtualizer.measure(); @@ -450,11 +957,36 @@ export const GalleryImageGrid = () => { // with compiler memoization. /** Returns whether the item had somewhere to scroll to — a collapsed strip has none. */ const scrollToItemKey = (itemKey: GalleryItemKey): boolean => { - const rowIndex = getGalleryGridRowIndexForItemKey(gallery.items, itemKey, columnCount); + if (usesSparseListing && sparseListing) { + const recentIndex = sparseRecentItems.findIndex((item) => toGalleryItemKey(item) === itemKey); - if (rowIndex >= 0) { - virtualizer.scrollToIndex(rowIndex); - return true; + if (recentIndex >= 0) { + const recentRow = Math.floor(recentIndex / columnCount); + virtualizer.scrollToIndex(sparseRecentAtTop ? recentRow : backendRowCount + recentRow); + + return true; + } + + const rowIndex = getGallerySparseRowIndexForItemKey( + sparseListing.itemSlots, + itemKey, + columnCount, + leadingRecentRows + ); + + if (rowIndex >= 0) { + virtualizer.scrollToIndex(rowIndex); + return true; + } + } + + if (!usesSparseListing) { + const rowIndex = getGalleryGridRowIndexForItemKey(gallery.items, itemKey, columnCount); + + if (rowIndex >= 0) { + virtualizer.scrollToIndex(rowIndex); + return true; + } } // Strip cells sit in the pinned block at the top of the scroll content. @@ -484,7 +1016,7 @@ export const GalleryImageGrid = () => { viewport.scrollTo({ top: rowBottom - viewport.clientHeight }); } } - } else { + } else if (entry.kind === 'item') { scrollToItemKey(toGalleryItemKey(entry.item)); } }; @@ -495,31 +1027,40 @@ export const GalleryImageGrid = () => { if (key) { const index = tileKeys.indexOf(key as GalleryItemKey); + const slot = sparseSlotEntries.find(([, item]) => toGalleryItemKey(item) === key)?.[0]; setFocusedTile((current) => - current?.key === key && current.index === index && current.filter === filter + current?.key === key && current.index === index && current.slot === slot && current.filter === filter ? current - : { filter, index, key: key as GalleryItemKey } + : { filter, index, key: key as GalleryItemKey, slot } ); } }, - [filter, tileKeys] + [filter, sparseSlotEntries, tileKeys] ); // A tile that leaves the document holding focus (deleted, moved between the strip and the listing, replaced by - // another board's) takes focus with it. The same item takes it back where it now shows, else the Tab stop, else - // the grid itself while it has no tiles, so the keys stay with the gallery; a user who already went elsewhere - // keeps their focus. + // another board's, its page evicted by scrolling away) takes focus with it. The same item takes it back where it + // now shows, else the Tab stop while in view, else the first tile in view, else the grid itself, so the keys stay + // with the gallery without pulling the next arrow back to a far-off tile; a user who already went elsewhere keeps + // their focus. const restoreTileFocus = useCallback((itemKey: GalleryItemKey) => { const viewport = viewportRef.current; const active = document.activeElement; - if (viewport && (active === null || active === document.body)) { - ( - viewport.querySelector(`[data-gallery-item-key="${CSS.escape(itemKey)}"]`) ?? - viewport.querySelector(GALLERY_TAB_STOP_SELECTOR) ?? - viewport - ).focus({ preventScroll: true }); + if (!viewport || (active !== null && active !== document.body)) { + return; + } + + const sameItem = viewport.querySelector(`[data-gallery-item-key="${CSS.escape(itemKey)}"]`); + + if (sameItem) { + sameItem.focus({ preventScroll: true }); + } else if ( + !focusVisibleOperable(viewport, { selector: GALLERY_TAB_STOP_SELECTOR }) && + !focusVisibleOperable(viewport, { selector: TILE_BUTTON_SELECTOR }) + ) { + viewport.focus({ preventScroll: true }); } }, []); @@ -622,16 +1163,120 @@ export const GalleryImageGrid = () => { getDialogReturnFocus, getFirstVisibleTileKey, getFocusedItem, - loadedItems, moveToEntry, navigationSections, + navigateToUnloadedSlot: handleNavigateToUnloadedSlot, + getSelectionPage, + onNavigationStart: cancelPendingSparseNavigation, selectItemRange, + shouldStar: shouldStarSelection, toggleItem, }); + const settlePendingSparseNavigation = useEffectEvent(() => { + const pending = pendingSparseNavigationRef.current; + + if (!pending) { + return; + } + + if (pending.filterIdentity !== sparseFilterIdentity || pending.originItemKey !== navigationOriginKey) { + pendingSparseNavigationRef.current = null; + return; + } + + if (pending.shouldRestoreFocus && pending.originFocusKey !== getFocusedTileKey()) { + pendingSparseNavigationRef.current = null; + return; + } + + const item = sparseListing?.itemSlots.get(pending.index); + const targetPageState = isSparsePaginated + ? sparseListing?.pageStates.get(sparsePageOffset) + : sparseListing?.pageStates.get(Math.floor(pending.index / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE); + + // A target page that settled or failed without the item leaves a gap; a later refresh must not land the step. + if (!item && targetPageState && !targetPageState.isLoading) { + pendingSparseNavigationRef.current = null; + return; + } + + if (item) { + pendingSparseNavigationRef.current = null; + const itemKey = toGalleryItemKey(item); + const selectionPage = getSelectionPage(item) ?? Math.floor(pending.index / GALLERY_PAGE_SIZE); + + pending.onResolved(itemKey); + + if (pending.mode === 'select') { + actions.selectItem(item, selectionPage); + } else if (pending.mode === 'extend') { + const isNavigationCurrent = () => pending.navigationGeneration === sparseNavigationGenerationRef.current; + const isFocusCurrent = () => !pending.shouldRestoreFocus || getFocusedTileKey() === itemKey; + + void selectItemRange(item, { + anchorKey: pending.anchorKey, + isFocusCurrent, + isNavigationCurrent, + selectionPage, + }); + } + + if (pending.shouldRestoreFocus && pending.originFocusKey) { + pendingSparseFocusRef.current = { + filterIdentity: pending.filterIdentity, + itemKey, + originFocusKey: pending.originFocusKey, + }; + setFocusedTile({ filter, index: tileKeys.indexOf(itemKey), key: itemKey }); + } + } + }); + + useEffect(() => { + settlePendingSparseNavigation(); + }, [navigationOriginKey, sparseFilterIdentity, sparseListing?.itemSlots, sparseListing?.pageStates]); + + useLayoutEffect(() => { + const pending = pendingSparseFocusRef.current; + + if (!pending) { + return; + } + + if (pending.filterIdentity !== sparseFilterIdentity) { + pendingSparseFocusRef.current = null; + return; + } + + if (focusedTile?.key !== pending.itemKey) { + return; + } + + const viewport = viewportRef.current; + + if (!viewport?.contains(document.activeElement) || getFocusedTileKey() !== pending.originFocusKey) { + pendingSparseFocusRef.current = null; + return; + } + + const target = viewport.querySelector(`[data-gallery-item-key="${CSS.escape(pending.itemKey)}"]`); + + if (target) { + pendingSparseFocusRef.current = null; + target.focus({ preventScroll: true }); + } + }, [focusedTile, sparseFilterIdentity, sparseListing?.itemSlots]); + // Only explicit reveals scroll. Retry while the item loads; retire the request when another selection supersedes // it. const revealRequest = useSyncExternalStore(subscribeGalleryRevealRequests, getGalleryRevealRequest); + // A reveal's index locates the item in the listing the selection navigates (Preview's), which keeps its own query + // after this grid's board, order, search, filter or ranking changes. Only that same listing's index means a slot here. + const selectedImageQuery = getGallerySelectedImageQuery(galleryValues); + const isRevealIndexInScope = + isGallerySelectionInScope(selectedImageQuery, gallery) && + (selectedImageQuery.semanticKey ?? '') === gallerySemanticReferenceKey(gallery.semanticImageQuery); const pendingRevealRef = useRef(null); // Honor requests preceding mount; selection mismatch, rather than request age, determines staleness. const consumedRevealTokenRef = useRef(0); @@ -642,6 +1287,12 @@ export const GalleryImageGrid = () => { return; } + if (pending.accountSignal.aborted) { + pendingRevealRef.current = null; + + return; + } + // Another selection retires the reveal; the persisted set catches // off-page selections whose visible key is null. if ( @@ -676,13 +1327,32 @@ export const GalleryImageGrid = () => { }); useEffect(() => { - if (revealRequest && revealRequest.token !== consumedRevealTokenRef.current) { + if (revealRequest?.accountSignal.aborted) { + if (pendingRevealRef.current?.token === revealRequest.token) { + pendingRevealRef.current = null; + } + } else if (revealRequest && revealRequest.token !== consumedRevealTokenRef.current) { consumedRevealTokenRef.current = revealRequest.token; pendingRevealRef.current = revealRequest; + + if (revealRequest.absoluteIndex !== undefined && isRevealIndexInScope && sparseListing && !isSparsePaginated) { + const indexedItem = sparseListing.itemSlots.get(revealRequest.absoluteIndex); + + if (!indexedItem || toGalleryItemKey(indexedItem) !== revealRequest.itemKey) { + requestSparseAbsoluteIndex(revealRequest.absoluteIndex, { verified: true }); + } + } } settlePendingReveal(); - }, [revealRequest, navigationSections]); + }, [ + isRevealIndexInScope, + isSparsePaginated, + navigationSections, + requestSparseAbsoluteIndex, + revealRequest, + sparseListing, + ]); useLayoutEffect(() => { const viewport = viewportRef.current; @@ -716,19 +1386,197 @@ export const GalleryImageGrid = () => { // Measure before paint after row-model changes: unchanged visible indices otherwise leave stale offsets despite // new row estimates. - useLayoutEffect(() => { + const restoreSparseScrollAnchor = useEffectEvent(() => { + const viewport = viewportRef.current; + const previousGeometry = lastMeasuredSparseGeometryRef.current; + const geometryChanged = + previousGeometry.columnCount !== columnCount || + previousGeometry.leadingRecentRows !== leadingRecentRows || + previousGeometry.pinnedHeight !== pinnedHeight || + previousGeometry.rowHeightPx !== rowHeightPx; + measureVirtualizer(); - }, [rowHeightPx, rows, pinnedHeight]); + + if (usesSparseListing && sparseListing) { + const pageOffset = isSparsePaginated ? sparsePageOffset : 0; + const itemKeys = new Map( + [...sparseListing.itemSlots.entries()].map(([index, item]) => [index, toGalleryItemKey(item)]) + ); + const currentSnapshot = { + filterIdentity: sparseFilterIdentity, + itemKeys, + pageOffset, + total: sparseListing.total, + }; + const previousSnapshot = sparsePositionSnapshotRef.current; + const listingIdentityChanged = + previousSnapshot !== null && + (previousSnapshot.filterIdentity !== currentSnapshot.filterIdentity || + previousSnapshot.pageOffset !== currentSnapshot.pageOffset); + const positionsChanged = + previousSnapshot !== null && + (previousSnapshot.total !== currentSnapshot.total || + previousSnapshot.itemKeys.size !== itemKeys.size || + [...previousSnapshot.itemKeys].some(([index, itemKey]) => itemKeys.get(index) !== itemKey)); + + if (listingIdentityChanged) { + // Search and page navigation deliberately choose a new viewport. Do not carry an anchor across them. + sparseScrollAnchorRef.current = null; + } else if ((geometryChanged || positionsChanged) && viewport) { + const anchor = sparseScrollAnchorRef.current; + + if (anchor) { + const loadedMatch = [...sparseListing.itemSlots.entries()].find( + ([, item]) => toGalleryItemKey(item) === anchor.itemKey + ); + const anchorPageOffset = isSparsePaginated + ? sparsePageOffset + : Math.floor(anchor.absoluteIndex / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const anchorPageState = sparseListing.pageStates.get(anchorPageOffset); + const nearbyMatch = + !loadedMatch && positionsChanged && anchorPageState && !anchorPageState.error && !anchorPageState.isLoading + ? [...sparseListing.itemSlots.entries()] + .map(([index, item]) => ({ + absoluteIndex: isSparsePaginated ? sparsePageOffset + index : index, + item, + localIndex: index, + })) + .sort( + (left, right) => + Math.abs(left.absoluteIndex - anchor.absoluteIndex) - + Math.abs(right.absoluteIndex - anchor.absoluteIndex) || left.absoluteIndex - right.absoluteIndex + )[0] + : undefined; + const target = loadedMatch + ? { + absoluteIndex: isSparsePaginated ? sparsePageOffset + loadedMatch[0] : loadedMatch[0], + item: loadedMatch[1], + localIndex: loadedMatch[0], + } + : nearbyMatch; + + // A missing anchor is meaningful only after its own page has settled successfully. A page that left the + // active sparse range, is still loading, or failed a refetch may simply have been temporarily evicted. + if (target) { + const rowIndex = leadingRecentRows + Math.floor(target.localIndex / columnCount); + const rowTop = pinnedHeight + rowIndex * rowHeightPx; + const nextScrollTop = Math.max(0, rowTop - anchor.viewportOffsetPx); + + if (target.absoluteIndex !== anchor.absoluteIndex || geometryChanged) { + viewport.scrollTop = nextScrollTop; + } + sparseScrollAnchorRef.current = { + absoluteIndex: target.absoluteIndex, + itemKey: toGalleryItemKey(target.item), + viewportOffsetPx: rowTop - viewport.scrollTop, + }; + } + } + } + + sparsePositionSnapshotRef.current = currentSnapshot; + } else { + sparsePositionSnapshotRef.current = null; + } + + lastMeasuredSparseGeometryRef.current = { columnCount, leadingRecentRows, pinnedHeight, rowHeightPx }; + }); + + useLayoutEffect(() => { + restoreSparseScrollAnchor(); + }, [ + columnCount, + isSparsePaginated, + leadingRecentRows, + pinnedHeight, + rowHeightPx, + rows, + sparseFilterIdentity, + sparseListing?.itemSlots, + sparseListing?.pageStates, + sparseListing?.total, + sparsePageOffset, + usesSparseListing, + ]); const virtualRows = virtualizer.virtualItems; + const sparsePageStatusOffsets = useMemo(() => { + if (!sparseListing) { + return new Map(); + } + + const scrollOffset = virtualizer.scrollOffset; + const scrollHeight = virtualizer.scrollRect?.height; + const visibleRows = + scrollOffset === null || scrollHeight === undefined + ? virtualRows + : virtualRows.filter((row) => row.start >= scrollOffset && row.end <= scrollOffset + scrollHeight); + const firstVisibleRow = visibleRows[0]?.index; + const lastVisibleRow = visibleRows.at(-1)?.index; + + if (firstVisibleRow === undefined || lastVisibleRow === undefined) { + return new Map(); + } + + const firstBackendRow = Math.max(0, firstVisibleRow - leadingRecentRows); + const afterLastBackendRow = Math.min(backendRowCount, lastVisibleRow + 1 - leadingRecentRows); + const visibleStart = (isSparsePaginated ? sparsePageOffset : 0) + firstBackendRow * columnCount; + const visibleEnd = Math.min( + (isSparsePaginated ? sparsePageOffset : 0) + sparseBackendItemCount, + (isSparsePaginated ? sparsePageOffset : 0) + afterLastBackendRow * columnCount + ); + const offsets = new Map(); + + for (const [pageOffset, pageState] of sparseListing.pageStates) { + if (!pageState.error && !pageState.isLoading) { + continue; + } + + const pageStart = Math.max(pageOffset, visibleStart); + const pageEnd = Math.min(pageOffset + GALLERY_PAGE_SIZE, visibleEnd); + + for (let itemIndex = pageStart; itemIndex < pageEnd; itemIndex += 1) { + const listingIndex = isSparsePaginated ? itemIndex - sparsePageOffset : itemIndex; + + if (!sparseListing.itemSlots.has(listingIndex)) { + offsets.set(pageOffset, itemIndex); + break; + } + } + } + + return offsets; + }, [ + backendRowCount, + columnCount, + isSparsePaginated, + leadingRecentRows, + sparseBackendItemCount, + sparseListing, + sparsePageOffset, + virtualRows, + virtualizer.scrollOffset, + virtualizer.scrollRect?.height, + ]); + const sparsePageErrors = useMemo( + () => + [...(sparseListing?.pageStates ?? [])].flatMap(([pageOffset, pageState]) => + pageState.error && + !(pageOffset === 0 && listing.status === 'stale-error') && + !sparsePageStatusOffsets.has(pageOffset) + ? [{ pageOffset, pageState }] + : [] + ), + [listing.status, sparseListing?.pageStates, sparsePageStatusOffsets] + ); // From the rows in view and their overscan, never the Tab stop kept mounted far below them. const lastVisibleRowIndex = Math.min((virtualizer.range?.endIndex ?? 0) + GRID_OVERSCAN_ROWS, rowCount - 1); useEffect(() => { - if (paginationMode === 'infinite' && rowCount > 0 && lastVisibleRowIndex >= rowCount - 2) { + if (!usesSparseListing && paginationMode === 'infinite' && rowCount > 0 && lastVisibleRowIndex >= rowCount - 2) { actions.loadMore(); } - }, [actions, lastVisibleRowIndex, paginationMode, rowCount]); + }, [actions, lastVisibleRowIndex, paginationMode, rowCount, usesSparseListing]); const handleDragEnter = useCallback((event: DragEvent) => { if (!dragEventContainsFiles(event)) { @@ -816,12 +1664,12 @@ export const GalleryImageGrid = () => { const focusGridEnd = useCallback(() => focusVisibleGridContent(viewportRef.current, 'last'), []); const renderCell = useCallback( - (item: GalleryItem) => { + (item: GalleryItem, slotKey: string = toGalleryItemKey(item), selectionPage?: number) => { const itemKey = toGalleryItemKey(item); return ( { onFocusLost={restoreTileFocus} onOpen={handleOpenItem} onToggleStarred={handleToggleStarred} + selectionPage={selectionPage} /> ); }, @@ -868,12 +1717,14 @@ export const GalleryImageGrid = () => { ); const anchoredWindowFirstItem = gallery.anchoredWindowPage * GALLERY_PAGE_SIZE + 1; - + const sparseErrorPageOffset = [...(sparseListing?.pageStates ?? [])].find(([, pageState]) => pageState.error)?.[0]; return ( - {listing.status === 'stale-error' ? ( + {listing.status === 'stale-error' && + (!usesSparseListing || (Boolean(sparseListing?.pageStates.get(0)?.error) && !sparsePageStatusOffsets.has(0))) ? ( { onFocusLost={focusGridContent} /> ) : null} + {usesSparseListing && listing.status !== 'error' && sparsePageErrors.length > 0 ? ( + + {sparsePageErrors.map(({ pageOffset, pageState }) => ( + + 0 ? focusGridEnd : focusGridContent} + pageOffset={pageOffset} + pageState={pageState} + /> + + ))} + + ) : null} { onDragOver={handleDragOver} onDrop={handleDrop} > - {gallery.anchoredWindowPage > 0 ? ( + {gallery.anchoredWindowPage > 0 && !usesSparseListing ? ( {t('widgets.gallery.windowAnchored', { index: anchoredWindowFirstItem })} @@ -974,8 +1838,11 @@ export const GalleryImageGrid = () => { ) : null} {listing.status === 'error' ? ( - // A failed scope never reads as an empty board, a search with no matches, or another scope's items. - + { /> ) : isEmpty && isStarredFailedEmpty && listing.status !== 'loading' ? ( - // The unstarred listing is empty, but the board's starred items are unknown: neither "empty board" - // nor "no matches" would be true, so only the strip's failure speaks. ) : isEmpty ? ( listing.status === 'loading' || hasActiveSearch || isVirtualBoard || gallery.starredOnly ? ( @@ -1035,6 +1900,91 @@ export const GalleryImageGrid = () => { w="full" > {virtualRows.map((virtualRow) => { + if (usesSparseListing && sparseListing) { + const recentRow = sparseRecentAtTop ? virtualRow.index : virtualRow.index - backendRowCount; + const isRecentRow = sparseRecentAtTop + ? virtualRow.index < sparseRecentRowCount + : virtualRow.index >= backendRowCount; + + if (isRecentRow) { + const firstRecentIndex = recentRow * columnCount; + const recentRowItems = sparseRecentItems.slice( + firstRecentIndex, + firstRecentIndex + columnCount + ); + + return ( + + {recentRowItems.map((item) => + renderCell(item, `recent-slot:${toGalleryItemKey(item)}`) + )} + + ); + } + + const backendRow = virtualRow.index - leadingRecentRows; + const firstItemIndex = backendRow * columnCount; + const itemCount = Math.max(0, Math.min(columnCount, sparseBackendItemCount - firstItemIndex)); + const cells = Array.from({ length: itemCount }, (_, column) => { + const itemIndex = firstItemIndex + column; + const item = sparseListing.itemSlots.get(itemIndex); + const absoluteItemIndex = sparsePageOffset + itemIndex; + + if (item) { + return renderCell( + item, + getGallerySparseSlotKey(absoluteItemIndex), + Math.floor(absoluteItemIndex / GALLERY_PAGE_SIZE) + ); + } + + const pageOffset = Math.floor(absoluteItemIndex / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + + return ( + + ); + }); + + return ( + + {cells} + + ); + } + const row = rows[virtualRow.index]; if (!row) { @@ -1055,13 +2005,13 @@ export const GalleryImageGrid = () => { transform={`translateY(${virtualRow.start - pinnedHeight}px)`} w="full" > - {row.cells.map(renderCell)} + {row.cells.map((item) => renderCell(item))} ); })} - {listing.status === 'more-error' ? ( + {!usesSparseListing && listing.status === 'more-error' ? ( // Loaded pages stay; only the page that failed waits on Retry. { retryLabel={t('widgets.gallery.retryLoadingMoreItems')} onFocusLost={focusGridEnd} /> - ) : paginationMode === 'infinite' && + ) : !usesSparseListing && + paginationMode === 'infinite' && (listing.isFetchingMore || listing.status === 'loading') && gallery.items.length > 0 ? ( ) : null} - {paginationMode === 'infinite' && !listing.isFetchingMore && isWindowTruncated && ( - - - {gallery.anchoredWindowPage > 0 - ? t('widgets.gallery.windowLimitFrom', { - count: gallery.items.length, - index: anchoredWindowFirstItem, - }) - : t('widgets.gallery.windowLimit', { count: gallery.items.length })} - - - )} + {!usesSparseListing && + paginationMode === 'infinite' && + !listing.isFetchingMore && + isWindowTruncated && ( + + + {gallery.anchoredWindowPage > 0 + ? t('widgets.gallery.windowLimitFrom', { + count: gallery.items.length, + index: anchoredWindowFirstItem, + }) + : t('widgets.gallery.windowLimit', { count: gallery.items.length })} + + + )} )} diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx index 32cbe4be99c..e676a75ade9 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx @@ -284,8 +284,10 @@ const scrollToLoadMoreFailure = async () => { const rect = findButton('Retry loading more items')!.parentElement!.getBoundingClientRect(); const viewportRect = viewport.getBoundingClientRect(); - const tiles = [...listing()!.querySelectorAll('[role="listitem"]')]; - // The end of the bottom row; tiles of one row may differ by a subpixel. + const tiles = [...listing()!.querySelectorAll('[role="listitem"]')].filter((tile) => + tile.querySelector('button[aria-pressed]') + ); + // The last loaded item; sparse slots can remain between this row and a later page. const lastTile = tiles.reduce((last, tile) => tile.getBoundingClientRect().bottom > last.getBoundingClientRect().bottom - 1 ? tile : last ); @@ -294,10 +296,17 @@ const scrollToLoadMoreFailure = async () => { inViewport: rect.top >= viewportRect.top - 1 && rect.bottom <= viewportRect.bottom + 1, lastTileBottom: lastTile.getBoundingClientRect().bottom, lastTileName: lastTile.querySelector('button[aria-pressed]')?.getAttribute('aria-label'), + lastTileRect: lastTile.getBoundingClientRect(), overlappingTiles: tiles.filter((tile) => { const tileRect = tile.getBoundingClientRect(); - return tileRect.top < rect.bottom && tileRect.bottom > rect.top; + return ( + Boolean(tile.querySelector('button[aria-pressed]')) && + tileRect.left < rect.right && + tileRect.right > rect.left && + tileRect.top < rect.bottom && + tileRect.bottom > rect.top + ); }).length, rect, }; @@ -381,10 +390,11 @@ describe('Gallery listing failures', () => { }); it('keeps loaded pages when the next page fails, offers Retry there, and substitutes no recents', async () => { - // A short first page of a long board: the end of the grid is in view, so the next page is requested at once. - transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(DOG_ITEMS, 500) : fail())); + // Sparse rows retain absolute positions; scroll to the next page and verify its local recovery control. + transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(DOG_ITEMS, 61) : fail())); await renderGallery({ recentImages: [OTHER_BOARD_RECENT] }); + await scrollToLoadMoreFailure(); await waitFor(() => expect(host?.textContent).toContain('Could not load more items.')); expect(thumbnailNames()).toEqual([ 'Select a.png for preview', @@ -401,7 +411,7 @@ describe('Gallery listing failures', () => { expect(transport.listItems.mock.calls.length).toBe(failedCalls); transport.listItems.mockImplementation(({ offset }) => - offset === 0 ? page(DOG_ITEMS, 4) : page([image('d.png', 'dogs', 0)], 4) + offset === 0 ? page(DOG_ITEMS, 61) : page([image('d.png', 'dogs', 0)], 61) ); await act(() => findButton('Retry loading more items')?.click()); @@ -411,6 +421,95 @@ describe('Gallery listing failures', () => { expect(transport.listItems.mock.calls.slice(failedCalls).map(([request]) => request.offset)).toEqual([60]); }); + it('keeps a failed distant page retry visible when its first slot is above the viewport', async () => { + transport.listItems.mockImplementation(({ offset }) => + offset === 420 ? fail() : page(dogs(Math.min(60, 600 - offset), offset), 600) + ); + await renderGallery(); + + await waitFor(() => expect(offsetsRequested()).toContain(0)); + const viewport = gridViewport()!; + + await act(() => { + viewport.scrollTop = Math.floor((viewport.scrollHeight - viewport.clientHeight) * 0.75); + viewport.dispatchEvent(new Event('scroll')); + }); + + await waitFor(() => expect(offsetsRequested()).toContain(420)); + await waitFor(() => expect(findButton('Retry loading more items')).not.toBeNull()); + const retry = findButton('Retry loading more items'); + expect(retry).not.toBeNull(); + const rect = retry!.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + + expect(rect.top).toBeGreaterThanOrEqual(viewportRect.top - 1); + expect(rect.bottom).toBeLessThanOrEqual(viewportRect.bottom + 1); + + await act(() => retry!.click()); + await waitFor(() => expect(offsetsRequested().filter((offset) => offset === 420)).toHaveLength(2)); + }); + + it('keeps a visible retry when the first failed row is only partly visible', async () => { + transport.listItems.mockImplementation(({ offset }) => + offset === 420 ? fail() : page(dogs(Math.min(60, 600 - offset), offset), 600) + ); + await renderGallery(); + await waitFor(() => expect(offsetsRequested()).toContain(0)); + + const viewport = gridViewport()!; + + await act(() => { + viewport.scrollTop = Math.floor((viewport.scrollHeight - viewport.clientHeight) * 0.75); + viewport.dispatchEvent(new Event('scroll')); + }); + await waitFor(() => expect(offsetsRequested()).toContain(420)); + await waitFor(() => expect(findButton('Retry loading more items')).not.toBeNull()); + + const retry = findButton('Retry loading more items')!; + const statusCell = retry.closest('[role="listitem"]')!; + const viewportRect = viewport.getBoundingClientRect(); + const cellRect = statusCell.getBoundingClientRect(); + const visibleCellHeight = cellRect.height * 0.2; + + await act(() => { + viewport.scrollTop += cellRect.top - (viewportRect.top - cellRect.height + visibleCellHeight); + viewport.dispatchEvent(new Event('scroll')); + }); + await settleFrames(); + const visibleRetry = [ + ...(host?.querySelectorAll('button[aria-label="Retry loading more items"]') ?? []), + ].find((button) => { + const rect = button.getBoundingClientRect(); + const currentViewportRect = viewport.getBoundingClientRect(); + + return rect.top >= currentViewportRect.top - 1 && rect.bottom <= currentViewportRect.bottom + 1; + }); + const pageRetry = host?.querySelector( + '[data-gallery-page-error="420"] button[aria-label="Retry loading more items"]' + ); + const retryControl = visibleRetry ?? pageRetry; + + expect(retryControl).not.toBeNull(); + if (visibleRetry) { + const rect = visibleRetry.getBoundingClientRect(); + const currentViewportRect = viewport.getBoundingClientRect(); + + expect(rect.top).toBeGreaterThanOrEqual(currentViewportRect.top - 1); + expect(rect.bottom).toBeLessThanOrEqual(currentViewportRect.bottom + 1); + } else { + const rect = pageRetry!.getBoundingClientRect(); + + expect(rect.top).toBeGreaterThanOrEqual(0); + expect(rect.bottom).toBeLessThanOrEqual(document.documentElement.clientHeight); + } + + transport.listItems.mockImplementation(({ offset }) => + offset === 420 ? page([image('recovered.png', 'dogs')], 600) : page(dogs(Math.min(60, 600 - offset), offset), 600) + ); + await act(() => retryControl!.click()); + await waitFor(() => expect(offsetsRequested().filter((offset) => offset === 420)).toHaveLength(2)); + }); + it("keeps a scope's earlier results through a failed refresh, with a notice that Retry clears", async () => { await renderGallery(); await waitFor(() => expect(thumbnailNames()).toHaveLength(3)); @@ -443,8 +542,9 @@ describe('Gallery listing failures', () => { }); it('keeps a failed next page failed through an unrelated refetch, without retrying it on its own', async () => { - transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(DOG_ITEMS, 500) : fail())); + transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(DOG_ITEMS, 61) : fail())); await renderGallery(); + await scrollToLoadMoreFailure(); await waitFor(() => expect(host?.textContent).toContain('Could not load more items.')); const failedPageRequests = offsetsRequested().filter((offset) => offset === 60).length; @@ -461,7 +561,7 @@ describe('Gallery listing failures', () => { }); it('shows the load-more failure after the last loaded row, in view at the end of the grid', async () => { - transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(dogs(60), 500) : fail())); + transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(dogs(60), 61) : fail())); await renderGallery(); await waitFor(() => expect(thumbnailNames().length).toBeGreaterThan(0)); @@ -483,7 +583,7 @@ describe('Gallery listing failures', () => { await userEvent.tab(); expect(document.activeElement?.getAttribute('aria-label')).toBe('Retry loading more items'); - transport.listItems.mockImplementation(({ offset }) => page(dogs(60, offset), 500)); + transport.listItems.mockImplementation(({ offset }) => page(dogs(60, offset), 61)); await userEvent.keyboard('{Enter}'); await waitFor(() => expect(findButton('Retry loading more items')).toBeNull()); await settleFrames(2); @@ -502,9 +602,9 @@ describe('Gallery listing failures', () => { expect(tileInPlace!.getBoundingClientRect().top).toBeLessThan(viewportRect.bottom); }); - it('places the load-more failure after the rows below the starred strip and progress tiles, at any density', async () => { + it('places the local page retry after loaded tiles below starred and progress sections, at any density', async () => { for (const imageDensityPercent of [0, 100]) { - transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(dogs(60), 500) : fail())); + transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(dogs(60), 61) : fail())); transport.listStarred.mockImplementation(() => page(Array.from({ length: 4 }, (_, index) => ({ ...image(`fav-${index}.png`, 'dogs', 9), starred: true }))) ); @@ -517,7 +617,10 @@ describe('Gallery listing failures', () => { expect(notice.inViewport, `density ${imageDensityPercent}`).toBe(true); expect(notice.overlappingTiles, `density ${imageDensityPercent}`).toBe(0); expect(notice.lastTileName, `density ${imageDensityPercent}`).toBe('Select dog-059.png for preview'); - expect(notice.rect.top, `density ${imageDensityPercent}`).toBeGreaterThanOrEqual(notice.lastTileBottom); + const followsLastTile = + notice.rect.top >= notice.lastTileBottom || + (notice.rect.left >= notice.lastTileRect.right && notice.rect.top < notice.lastTileBottom); + expect(followsLastTile, `density ${imageDensityPercent}`).toBe(true); await act(() => root?.render(null)); queryClient?.clear(); @@ -525,7 +628,7 @@ describe('Gallery listing failures', () => { }); it('hands focus to the grid beside the new items after a load-more Retry, without scrolling', async () => { - transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(dogs(60), 500) : fail())); + transport.listItems.mockImplementation(({ offset }) => (offset === 0 ? page(dogs(60), 61) : fail())); await renderGallery(); await waitFor(() => expect(thumbnailNames().length).toBeGreaterThan(0)); @@ -542,7 +645,7 @@ describe('Gallery listing failures', () => { transport.listItems.mockImplementation( ({ offset }) => new Promise((resolve) => { - deliverNextPage = () => resolve({ items: dogs(60, offset), total: 500 }); + deliverNextPage = () => resolve({ items: dogs(60, offset), total: 61 }); }) ); // A real click, which focuses the button as a user's would; it keeps focus while the retry runs. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.browser.test.tsx index 89c5295c527..fd4c6478710 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.browser.test.tsx @@ -4,6 +4,8 @@ import type { GalleryBoard } from '@features/gallery/core/types'; import { ChakraProvider } from '@chakra-ui/react'; import { DEFAULT_GALLERY_SETTINGS } from '@features/gallery/core/settings'; +import { patchGalleryItemCaches } from '@features/gallery/data/queryCache'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -92,6 +94,7 @@ const createGallery = (overrides: Partial = {}): GalleryStateV let host: HTMLDivElement | null = null; let root: Root | null = null; +let queryClient = new QueryClient(); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const renderBar = async ( @@ -100,6 +103,7 @@ const renderBar = async ( ) => { const strip = starredStrip ?? EMPTY_GALLERY_STARRED_STRIP; const contextValue = { + filter: { boardId: gallery.selectedBoardId, starred: gallery.starredOnly === true }, gallery, itemActions, loadedItems: mergeGalleryLoadedItems(strip.items, gallery.items), @@ -108,11 +112,13 @@ const renderBar = async ( await act(() => root?.render( - - - - - + + + + + + + ) ); }; @@ -138,6 +144,7 @@ beforeEach(() => { host = document.createElement('div'); document.body.append(host); root = createRoot(host); + queryClient = new QueryClient(); Object.values(itemActions).forEach((mock) => mock.mockClear()); }); @@ -181,6 +188,154 @@ describe('GallerySelectionBar', () => { expect(itemActions.setItemsStarred).toHaveBeenCalledWith(expect.anything(), false); }); + it('unstars a starred-only selection whose members no page has loaded', async () => { + const selectedItemKeys = ['image:a.png', 'image:never-loaded-1.png', 'image:never-loaded-2.png'] as const; + await renderBar( + createGallery({ + items: [createItem('a.png', true)], + selectedItemKey: 'image:a.png', + selectedItemKeys: [...selectedItemKeys], + selectionStarredOnly: true, + starredOnly: true, + }) + ); + await click(getButton('widgets.gallery.unstarSelection')); + + expect(itemActions.setItemsStarred).toHaveBeenCalledExactlyOnceWith( + [ + { kind: 'image', name: 'a.png' }, + { kind: 'image', name: 'never-loaded-1.png' }, + { kind: 'image', name: 'never-loaded-2.png' }, + ], + false + ); + }); + + it('stars a selection carried into Starred-only from the unfiltered listing whose members no page has loaded', async () => { + await renderBar( + createGallery({ + items: [createItem('a.png', true)], + selectedItemKey: 'image:a.png', + selectedItemKeys: ['image:a.png', 'image:selected-unfiltered.png'], + selectionStarredOnly: false, + starredOnly: true, + }) + ); + await click(getButton('widgets.gallery.starSelection')); + + expect(itemActions.setItemsStarred).toHaveBeenCalledExactlyOnceWith( + [ + { kind: 'image', name: 'a.png' }, + { kind: 'image', name: 'selected-unfiltered.png' }, + ], + true + ); + }); + + it('retains the selected item star state after its sparse page is evicted', async () => { + const gallery = createGallery({ + items: [createItem('b.png', true)], + selectedItemKey: 'image:b.png', + selectedItemKeys: ['image:b.png'], + }); + + await renderBar(gallery); + await renderBar({ ...gallery, items: [] }); + + expect(getButton('widgets.gallery.unstarSelection')).toBeTruthy(); + await click(getButton('widgets.gallery.unstarSelection')); + + expect(itemActions.setItemsStarred).toHaveBeenCalledExactlyOnceWith([{ kind: 'image', name: 'b.png' }], false); + }); + + it.each([ + // A starred-only listing drops the item as the patch lands. + ['leaves the listing with the patch', false], + ['sits on a page evicted earlier', true], + ] as const)('follows a star patch and its rollback for a selected item that %s', async (_case, evictedFirst) => { + const gallery = createGallery({ + items: [createItem('b.png', true)], + selectedItemKey: 'image:b.png', + selectedItemKeys: ['image:b.png'], + }); + const unloaded = { ...gallery, items: [] }; + + await renderBar(gallery); + if (evictedFirst) { + await renderBar(unloaded); + } + + let rollback = () => {}; + await act(() => { + rollback = patchGalleryItemCaches(queryClient, { + kind: 'star', + result: { failed: [], succeeded: [{ kind: 'image', name: 'b.png' }] }, + starred: false, + }); + }); + await renderBar(unloaded); + + expect(getButton('widgets.gallery.starSelection')).toBeTruthy(); + await click(getButton('widgets.gallery.starSelection')); + expect(itemActions.setItemsStarred).toHaveBeenCalledExactlyOnceWith([{ kind: 'image', name: 'b.png' }], true); + + await act(() => rollback()); + + expect(getButton('widgets.gallery.unstarSelection')).toBeTruthy(); + }); + + it('keeps a retained star flag for an item that stays selected when the selection grows', async () => { + const gallery = createGallery({ + items: [createItem('a.png', true)], + selectedItemKey: 'image:a.png', + selectedItemKeys: ['image:a.png'], + starredOnly: true, + }); + + await renderBar(gallery); + // A's page leaves the window, then starred B joins the selection. + await renderBar({ ...gallery, items: [] }); + await renderBar({ + ...gallery, + items: [createItem('b.png', true)], + selectedItemKey: 'image:b.png', + selectedItemKeys: ['image:a.png', 'image:b.png'], + }); + + expect(getButton('widgets.gallery.unstarSelection')).toBeTruthy(); + }); + + it('restores a retained flag when a patch made before the selection grew is reverted', async () => { + const gallery = createGallery({ + items: [createItem('a.png', true)], + selectedItemKey: 'image:a.png', + selectedItemKeys: ['image:a.png'], + }); + const grown: GalleryStateView = { + ...gallery, + items: [createItem('b.png', true)], + selectedItemKey: 'image:b.png', + selectedItemKeys: ['image:a.png', 'image:b.png'], + }; + + await renderBar(gallery); + await renderBar({ ...gallery, items: [] }); + let rollback = () => {}; + await act(() => { + rollback = patchGalleryItemCaches(queryClient, { + kind: 'star', + result: { failed: [], succeeded: [{ kind: 'image', name: 'a.png' }] }, + starred: false, + }); + }); + await renderBar(grown); + expect(getButton('widgets.gallery.starSelection')).toBeTruthy(); + + await act(() => rollback()); + + expect(getButton('widgets.gallery.unstarSelection')).toBeTruthy(); + }); + it('reads star state from the strip for a selection the listing window has not loaded', async () => { await renderBar(createGallery({ items: [] }), { items: [createItem('a.png', true), createItem('b.png', true)], diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.tsx index 3018d8adff6..38e3444dd39 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySelectionBar.tsx @@ -2,7 +2,7 @@ import type { GalleryBoard } from '@features/gallery/core/types'; import { HStack, Icon, Menu, Portal, Text } from '@chakra-ui/react'; import { getGalleryBoardLabel } from '@features/gallery/core/boardLabels'; -import { parseGalleryItemKey, shouldStarSelection } from '@features/gallery/core/items'; +import { parseGalleryItemKey } from '@features/gallery/core/items'; import { IconButton } from '@platform/ui/Button'; import { MenuContent } from '@platform/ui/Menu'; import { Tooltip } from '@platform/ui/Tooltip'; @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'; import { BoardCover } from './GalleryBoardCover'; import { useMenuTriggerIds } from './galleryMenuIds'; -import { useGalleryWidget } from './GalleryWidgetContext'; +import { useGallerySelectionStarred, useGalleryWidget } from './GalleryWidgetContext'; const MOVE_MENU_POSITIONING = { placement: 'top-end' } as const; @@ -27,7 +27,7 @@ export const GallerySelectionBar = () => { // Star acts on the whole selection: only "unstar all" when every selected // item is already starred, matching the `.` hotkey. - const shouldStar = useMemo(() => shouldStarSelection(loadedItems, selectedItemRefs), [loadedItems, selectedItemRefs]); + const shouldStar = useGallerySelectionStarred(selectedItemRefs, loadedItems); const moveTargets = useMemo( () => diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx index f01c32fb589..e1fb1d5facb 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx @@ -378,11 +378,19 @@ const GalleryThumbnail = ({ export const GalleryThumbnailCell = ({ getDragItems, item, + onClick, + selectionPage, ...props -}: Omit[0], 'dragItems'> & { +}: Omit[0], 'dragItems' | 'onClick'> & { getDragItems: (item: GalleryItem) => GalleryItemRef[]; + onClick: (item: GalleryItem, event: MouseEvent, selectionPage?: number) => void; + selectionPage?: number; }) => { const dragItems = useMemo(() => getDragItems(item), [getDragItems, item]); + const handleClick = useCallback( + (clickedItem: GalleryItem, event: MouseEvent) => onClick(clickedItem, event, selectionPage), + [onClick, selectionPage] + ); - return ; + return ; }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx index 29f199ee28c..c68d5a91799 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx @@ -39,6 +39,8 @@ export interface GalleryItemActionsOptions { } export interface GalleryItemContextMenuTarget { + /** Whether every target is starred, when the host knows more than `items`, which omits unloaded targets. */ + allStarred?: boolean; itemRefs: GalleryItemRef[]; items: GalleryItem[]; x: number; @@ -55,11 +57,11 @@ export interface GalleryCommandsPort { clearSelection(): void; reconcileDeletedBoardOutcome(outcome: GalleryBoardDeletionResult): void; selectBoard(boardId: string): void; - selectItem(item: GalleryItem): void; + selectItem(item: GalleryItem, selectionPage?: number): void; selectImage(image: GalleryImage): void; setCompareItem(image: GalleryImageItem | null): void; setCompareImage(image: GalleryImage | null): void; - setItemMultiSelection(itemKeys: GalleryItemKey[], primaryItem: GalleryItem): void; + setItemMultiSelection(itemKeys: GalleryItemKey[], primaryItem: GalleryItem, selectionPage?: number): void; setPage(page: number): void; setPageInfo(totalImages: number): void; setSearchTerm(searchTerm: string): void; @@ -69,7 +71,8 @@ export interface GalleryCommandsPort { commitSemanticSearch(text: string): void; clearSearch(): void; setView(view: GalleryView): void; - toggleItemSelection(item: GalleryItem, nextPrimaryItem: GalleryItem | null): void; + /** `selectionPage` is the grid page of whichever item becomes primary: `item` when added, else `nextPrimaryItem`. */ + toggleItemSelection(item: GalleryItem, nextPrimaryItem: GalleryItem | null, selectionPage?: number): void; updateSettings(settings: Partial): void; } diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx index 8460deebcbd..356563f7cc9 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetContext.tsx @@ -3,12 +3,17 @@ import type { GallerySemanticReference } from '@features/gallery/core/semanticIm import type { GallerySettings } from '@features/gallery/core/settings'; import type { GalleryView } from '@features/gallery/core/types'; import type { GalleryItemsFilter } from '@features/gallery/data/queries'; +import type { GalleryItemStarPatchEvent } from '@features/gallery/data/queryCache'; -import { createContext, use } from 'react'; +import { toGalleryItemKey } from '@features/gallery/core/items'; +import { subscribeGalleryItemStarPatches } from '@features/gallery/data/queryCache'; +import { useMountEffect } from '@platform/react/useMountEffect'; +import { useQueryClient } from '@tanstack/react-query'; +import { createContext, use, useEffect, useMemo, useSyncExternalStore } from 'react'; import type { GalleryReadState, GalleryStateView } from './galleryStateView'; import type { GalleryItemActions, GalleryWidgetProps, GalleryWidgetRuntime } from './GalleryUiContext'; -import type { GalleryListingState } from './useGalleryData'; +import type { GalleryListingState, GallerySparseListing } from './useGalleryData'; /** * The provider maps widget intents to workbench/backend actions; shared ImageActions owns cross-widget item @@ -25,8 +30,8 @@ export interface GalleryActions { refresh: () => void; renameBoard: (boardId: string, boardName: string) => Promise; selectBoard: (boardId: string) => void; - selectItem: (item: GalleryItem) => void; - selectItemRange: (items: GalleryItemRef[], primaryItem: GalleryItem) => void; + selectItem: (item: GalleryItem, selectionPage?: number) => void; + selectItemRange: (items: GalleryItemRef[], primaryItem: GalleryItem, selectionPage?: number) => void; setCompareItem: (image: GalleryImageItem | null) => void; setSearchTerm: (searchTerm: string) => void; /** Restricts (or releases) the listing to starred items; resets the page like a search. */ @@ -42,7 +47,7 @@ export interface GalleryActions { /** The semantic field's live text, ahead of the debounced commit. */ setSemanticSearchText: (text: string) => void; setView: (galleryView: GalleryView) => void; - toggleItemInSelection: (item: GalleryItem, nextPrimaryItem: GalleryItem | null) => void; + toggleItemInSelection: (item: GalleryItem, nextPrimaryItem: GalleryItem | null, selectionPage?: number) => void; updateSettings: (settings: Partial) => void; /** Resolves with the confirmed uploads; empty when nothing landed. */ uploadFiles: (files: File[]) => Promise; @@ -74,6 +79,12 @@ export interface GalleryWidgetContextValue { listing: GalleryListingState; /** Everything on hand — strip first, then the listing, without repeats — for lookups by key. */ loadedItems: GalleryItem[]; + /** Main Gallery's absolute page slots. Other Gallery surfaces continue to use a dense loaded projection. */ + sparseListing?: GallerySparseListing; + /** Reports the grid's virtual item range so only intersecting page queries stay subscribed. */ + setVisibleRange?: (range: { endIndexExclusive: number; startIndex: number }) => void; + /** See `GalleryData.pinRevealIndex`; offered for sparse infinite listings only. */ + pinRevealIndex?: (absoluteIndex: number) => void; starredStrip: GalleryStarredStrip; projectName: string; /** Placement, used only to scope cached viewport measurements. */ @@ -81,6 +92,130 @@ export interface GalleryWidgetContextValue { runtime: GalleryWidgetRuntime; } +interface SelectionStarSnapshot { + identity: string; + starredByKey: ReadonlyMap; +} + +interface SelectionStarStore { + getSnapshot: () => SelectionStarSnapshot; + /** Applies a star patch to the selection, or restores the flags a reverted patch replaced. */ + reconcilePatch: (event: GalleryItemStarPatchEvent) => void; + subscribe: (listener: () => void) => () => void; + sync: (identity: string, selectedKeys: readonly string[], loadedItems: readonly GalleryItem[]) => void; +} + +const createSelectionStarStore = (): SelectionStarStore => { + let snapshot: SelectionStarSnapshot = { identity: '', starredByKey: new Map() }; + let selectedKeySet: ReadonlySet = new Set(); + // What each key held before the latest patch that set it; only that patch's rollback restores it. + let patchedFrom = new Map(); + const listeners = new Set<() => void>(); + const publish = (identity: string, starredByKey: ReadonlyMap) => { + if ( + snapshot.identity === identity && + starredByKey.size === snapshot.starredByKey.size && + [...starredByKey].every(([key, starred]) => snapshot.starredByKey.get(key) === starred) + ) { + return; + } + + snapshot = { identity, starredByKey }; + listeners.forEach((listener) => listener()); + }; + + return { + getSnapshot: () => snapshot, + reconcilePatch: (event) => { + const starredByKey = new Map(snapshot.starredByKey); + + if (event.kind === 'apply') { + for (const key of event.itemKeys) { + if (selectedKeySet.has(key)) { + patchedFrom.set(key, { patchId: event.patchId, starred: starredByKey.get(key) }); + starredByKey.set(key, event.starred); + } + } + } else { + for (const [key, { patchId, starred }] of patchedFrom) { + if (patchId !== event.patchId) { + continue; + } + + patchedFrom.delete(key); + + if (starred === undefined) { + starredByKey.delete(key); + } else { + starredByKey.set(key, starred); + } + } + } + + publish(snapshot.identity, starredByKey); + }, + subscribe: (listener) => { + listeners.add(listener); + + return () => listeners.delete(listener); + }, + sync: (identity, selectedKeys, loadedItems) => { + selectedKeySet = new Set(selectedKeys); + + // Items that stay selected keep their known flags when the selection changes around them. + const isStillSelected = ([key]: [string, unknown]) => selectedKeySet.has(key); + const starredByKey = new Map([...snapshot.starredByKey].filter(isStillSelected)); + + if (snapshot.identity !== identity) { + patchedFrom = new Map([...patchedFrom].filter(isStillSelected)); + } + + for (const item of loadedItems) { + const key = toGalleryItemKey(item); + + if (selectedKeySet.has(key)) { + starredByKey.set(key, item.starred); + } + } + + publish(identity, starredByKey); + }, + }; +}; + +/** + * Retains star flags for the current selection while sparse listing pages leave the loaded window, and follows star + * patches from any surface for selected items no loaded page holds. + */ +export const useGallerySelectionStarred = ( + selectedItems: readonly GalleryItemRef[], + loadedItems: readonly GalleryItem[] +): boolean => { + const { filter, gallery } = useGalleryWidget(); + const selectedKeys = useMemo(() => selectedItems.map(toGalleryItemKey), [selectedItems]); + const identity = useMemo(() => JSON.stringify(selectedKeys), [selectedKeys]); + const queryClient = useQueryClient(); + const store = useMemo(() => createSelectionStarStore(), []); + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + useMountEffect(() => subscribeGalleryItemStarPatches(queryClient, store.reconcilePatch)); + + useEffect(() => { + store.sync(identity, selectedKeys, loadedItems); + }, [identity, loadedItems, selectedKeys, store]); + + const loadedStarred = useMemo( + () => new Map(loadedItems.map((item) => [toGalleryItemKey(item), item.starred])), + [loadedItems] + ); + // In a starred-only listing every item is starred, including selected ones no page has loaded, but only when the + // selection was made in such a listing: one carried in from elsewhere may hold unstarred members. + const unknownStarred = filter.starred === true && gallery.selectionStarredOnly; + + // Flags are per item, so ones known before a selection change still answer for items that stay selected. + return selectedKeys.some((key) => !(loadedStarred.get(key) ?? snapshot.starredByKey.get(key) ?? unknownStarred)); +}; + export const GalleryWidgetContext = createContext(null); export const useGalleryWidget = (): GalleryWidgetContextValue => { diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx index 93355fb0fd7..d824a6c20da 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.browser.test.tsx @@ -1,10 +1,33 @@ +import type { GalleryImageItem } from '@features/gallery/core/items'; + import { ChakraProvider } from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; -import { act } from 'react'; +import { act, type ComponentProps } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { GalleryStatusChip } from './GalleryWidgetView'; +import type { GalleryUiAdapter } from './GalleryUiContext'; + +import { GalleryItemActionsProvider, GalleryUiProvider } from './GalleryUiContext'; +import { GalleryStatusChip, GalleryWidgetView } from './GalleryWidgetView'; + +const mocks = vi.hoisted(() => ({ + galleryData: null as unknown, + getItemActionContext: null as null | ComponentProps['getItemActionContext'], +})); + +vi.mock('./GalleryBoardDragMonitor', () => ({ GalleryBoardDragMonitor: () => null })); +vi.mock('./GalleryLayout', () => ({ GalleryLayout: () => null })); +vi.mock('./useGalleryActions', () => ({ useGalleryActions: () => ({}) })); +vi.mock('./useGalleryData', () => ({ useGalleryData: () => mocks.galleryData })); +vi.mock('./useGalleryStarredStrip', () => ({ + useGalleryStarredStrip: () => ({ + items: [], + state: { error: null, isFetchingMore: false, isRetrying: false, retry: () => Promise.resolve(), status: 'ready' }, + total: 0, + }), +})); vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -30,6 +53,81 @@ afterEach(async () => { root = null; }); +const createImageItem = (name: string): GalleryImageItem => ({ + boardId: 'none', + category: 'general', + createdAt: '2026-10-05T00:00:00.000Z', + fullUrl: `/api/v1/images/i/${name}/full`, + height: 768, + isIntermediate: false, + kind: 'image', + name, + starred: false, + thumbnailUrl: `/api/v1/images/i/${name}/thumbnail`, + width: 512, +}); + +const itemActions = { + deleteItems: vi.fn(), + downloadItem: vi.fn(), + downloadItems: vi.fn(), + moveItemsToBoard: vi.fn(), + openItemInNewTab: vi.fn(), + openItemInPreview: vi.fn(), + setItemsStarred: vi.fn(), +}; +const noop = vi.fn(); + +const CapturingItemActionsProvider = ({ + children, + getItemActionContext, +}: ComponentProps) => { + // eslint-disable-next-line react/immutability -- The browser test reads the props supplied to this provider. + mocks.getItemActionContext = getItemActionContext ?? null; + return {children}; +}; + +const adapter: GalleryUiAdapter = { + ItemActionsProvider: CapturingItemActionsProvider, + ImageContextMenu: () => null, + antialiasProgressImages: false, + exportProject: noop, + followedProgressSessionId: null, + followProgressSession: noop, + gallery: { + clearSelection: noop, + clearSearch: noop, + commitSemanticSearch: noop, + reconcileDeletedBoardOutcome: noop, + selectBoard: noop, + selectImage: noop, + selectItem: noop, + setCompareImage: noop, + setCompareItem: noop, + setItemMultiSelection: noop, + setPage: noop, + setPageInfo: noop, + setSearchTerm: noop, + setSemanticSearchMode: noop, + setSemanticSearchText: noop, + setStarredOnly: noop, + setView: noop, + toggleItemSelection: noop, + updateSettings: noop, + }, + galleryValues: { galleryPage: 0, selectedBoardId: 'none' }, + generateValues: {}, + getItemLabel: () => Promise.resolve(null), + liveFollowEnabled: false, + notifications: { add: noop, reportError: noop }, + pinnedProgressSessionId: null, + progressSessions: [], + projectId: 'project-1', + projectName: 'Project', + widgets: { openGallery: () => true, patchGalleryValues: noop }, +}; +const runtime = { commands: { register: noop }, hotkeys: { register: noop } }; + it('renders the compact gallery status through the localized status-chip key', async () => { await act(() => root?.render( @@ -42,3 +140,42 @@ it('renders the compact gallery status through the localized status-chip key', a expect(document.body.textContent).toContain('Gallery: 12 items'); expect(document.body.textContent).not.toContain('widgets.gallery.statusChip'); }); + +it('exposes sparse ranking pages to item context-menu actions', async () => { + const deepItem = createImageItem('deep-result'); + const queryClient = new QueryClient(); + mocks.galleryData = { + boards: [], + boardsState: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, + filter: { boardId: 'none' }, + isLoadingItems: false, + isWindowTruncated: false, + items: [deepItem], + listing: { error: null, isFetchingMore: false, isRetrying: false, retry: noop, status: 'ready' }, + loadMore: noop, + queryError: null, + selectedBoardId: 'none', + setVisibleRange: noop, + sparseListing: { + itemSlots: new Map([[180, deepItem]]), + pageStates: new Map(), + recentItems: [], + total: 181, + }, + total: 181, + }; + + await act(() => + root?.render( + + + + + + + + ) + ); + + expect(mocks.getItemActionContext?.()?.getItemSelectionPage?.(deepItem)).toBe(3); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx index ae40bf4a74a..d5d78938076 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWidgetView.tsx @@ -2,7 +2,7 @@ import type { GalleryItem } from '@features/gallery/core/items'; import type { GalleryItemsFilter } from '@features/gallery/data/queries'; import type { TFunction } from 'i18next'; -import { toGalleryItemRef } from '@features/gallery/core/items'; +import { toGalleryItemKey, toGalleryItemRef } from '@features/gallery/core/items'; import { getBoundedRecentImages } from '@features/gallery/core/recentImages'; import { getGallerySettings } from '@features/gallery/core/settings'; import { GALLERY_PAGE_SIZE, galleryItemNamesOptions } from '@features/gallery/data/queries'; @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import type { GalleryReadState, GalleryReadStatus, GalleryStateView } from './galleryStateView'; import { GalleryBoardDragMonitor } from './GalleryBoardDragMonitor'; -import { mergeGalleryLoadedItems } from './galleryGridLayout'; +import { getGallerySparseSelectionPages, mergeGalleryLoadedItems } from './galleryGridLayout'; import { GalleryLayout } from './GalleryLayout'; import { GalleryAnnouncer } from './GalleryLoadError'; import { @@ -44,7 +44,7 @@ import { type GalleryWidgetContextValue, } from './GalleryWidgetContext'; import { useGalleryActions } from './useGalleryActions'; -import { useGalleryData, type GalleryListingState } from './useGalleryData'; +import { useGalleryData, type GalleryData, type GalleryListingState } from './useGalleryData'; import { useGalleryStarredStrip } from './useGalleryStarredStrip'; export const shouldPublishGalleryTotal = ({ @@ -116,6 +116,8 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidg settings, // The grid partitions: starred items live in the strip above it. starred: starredOnly, + // Compact bottom chips have no virtualized grid; keep their single page query on the dense listing path. + sparseViewport: region !== 'bottom' || presentation === 'expanded', }); const { loadMore, selectedBoardId, total } = data; @@ -134,6 +136,20 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidg () => mergeGalleryLoadedItems(starredStrip.items, gallery.items), [gallery.items, starredStrip.items] ); + const sparseSelectionPages = useMemo( + () => + data.sparseListing + ? getGallerySparseSelectionPages({ + itemSlots: data.sparseListing.itemSlots, + pageOffset: settings.paginationMode === 'paginated' ? page * GALLERY_PAGE_SIZE : 0, + }) + : null, + [data.sparseListing, page, settings.paginationMode] + ); + const getItemSelectionPage = useCallback( + (item: GalleryItem) => sparseSelectionPages?.get(toGalleryItemKey(item)) ?? page, + [page, sparseSelectionPages] + ); const lastPublishedTotalRef = useRef(null); const itemActionFilterIdentity = useMemo(() => JSON.stringify(data.filter), [data.filter]); const loadOrderedItemRefs = useCallback( @@ -154,6 +170,7 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidg // eslint-disable-next-line react/refs itemActionContextRef.current = { filterIdentity: itemActionFilterIdentity, + getItemSelectionPage, items: gallery.items, loadOrderedRefs: loadOrderedItemRefs, selectedItemKey: gallery.selectedItemKey, @@ -233,6 +250,9 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: GalleryWidg projectName={projectName} region={region} runtime={runtime} + pinRevealIndex={data.pinRevealIndex} + setVisibleRange={data.setVisibleRange} + sparseListing={data.sparseListing} starredStrip={starredStrip} /> @@ -277,9 +297,12 @@ const GalleryWidgetContent = ({ isWindowTruncated, listing, loadedItems, + pinRevealIndex, projectName, region, runtime, + setVisibleRange, + sparseListing, starredStrip, }: { actions: GalleryActions; @@ -289,9 +312,12 @@ const GalleryWidgetContent = ({ isWindowTruncated: boolean; listing: GalleryListingState; loadedItems: GalleryItem[]; + pinRevealIndex: GalleryData['pinRevealIndex']; projectName: string; region: GalleryWidgetProps['region']; runtime: GalleryWidgetRuntime; + setVisibleRange: ((range: { endIndexExclusive: number; startIndex: number }) => void) | undefined; + sparseListing: GalleryData['sparseListing']; starredStrip: GalleryStarredStrip; }) => { const { t } = useTranslation(); @@ -306,9 +332,12 @@ const GalleryWidgetContent = ({ itemActions, listing, loadedItems, + pinRevealIndex, projectName, region, runtime, + setVisibleRange, + sparseListing, starredStrip, }), [ @@ -320,9 +349,12 @@ const GalleryWidgetContent = ({ itemActions, listing, loadedItems, + pinRevealIndex, projectName, region, runtime, + setVisibleRange, + sparseListing, starredStrip, ] ); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts index 3e4e91bacfa..7dbd19dfcf9 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts @@ -1,9 +1,11 @@ import type { GalleryImageItem } from '@features/gallery/core/items'; +import { GALLERY_PAGE_SIZE } from '@features/gallery/core/paging'; import { describe, expect, it } from 'vitest'; import { buildGalleryGridRows, + buildSparseGalleryNavigationEntries, GALLERY_GRID_GAP_PX, GALLERY_PINNED_FOOTER_PX, GALLERY_STARRED_HEADER_HEIGHT_PX, @@ -11,9 +13,14 @@ import { getGalleryColumnCount, getGalleryColumnCountForCell, getGalleryGridRowIndexForItemKey, + getGallerySparseRowIndexForItemKey, + getGallerySparseRowKey, + getGallerySparseSelectionPages, + getGallerySparseSlotKey, getGalleryPinnedHeightPx, getGalleryStarredLayout, getGalleryStarredStripItems, + planGalleryPageOffsets, } from './galleryGridLayout'; const createImageItem = (name: string, starred = false): GalleryImageItem => ({ @@ -32,6 +39,36 @@ const createImageItem = (name: string, starred = false): GalleryImageItem => ({ const buildRows = (items: GalleryImageItem[]) => buildGalleryGridRows(items, 2); +describe('planGalleryPageOffsets', () => { + it.each([ + { endIndexExclusive: 0, expected: [], startIndex: 0, total: 0 }, + { endIndexExclusive: 59, expected: [0], startIndex: 2, total: 120 }, + { endIndexExclusive: GALLERY_PAGE_SIZE, expected: [0], startIndex: 0, total: 120 }, + { endIndexExclusive: GALLERY_PAGE_SIZE + 1, expected: [0, GALLERY_PAGE_SIZE], startIndex: 59, total: 120 }, + { endIndexExclusive: 1_000, expected: [0, GALLERY_PAGE_SIZE], startIndex: 58, total: 61 }, + { endIndexExclusive: 100, expected: [], startIndex: 90, total: 61 }, + { + endIndexExclusive: 721, + expected: Array.from({ length: 13 }, (_, index) => index * GALLERY_PAGE_SIZE), + startIndex: 0, + total: null, + }, + ])( + 'plans offsets for range [$startIndex, $endIndexExclusive) with total $total', + ({ endIndexExclusive, expected, startIndex, total }) => { + expect(planGalleryPageOffsets({ endIndexExclusive, startIndex, total })).toEqual(expected); + } + ); + + it('returns no offsets for empty or non-finite ranges', () => { + expect(planGalleryPageOffsets({ endIndexExclusive: 10, startIndex: 10, total: null })).toEqual([]); + expect(planGalleryPageOffsets({ endIndexExclusive: Number.POSITIVE_INFINITY, startIndex: 0, total: null })).toEqual( + [] + ); + expect(planGalleryPageOffsets({ endIndexExclusive: 4, startIndex: 5, total: null })).toEqual([]); + }); +}); + describe('getGalleryColumnCountForCell', () => { it('rounds to the nearest whole cell and clamps to the caller bounds', () => { const bounds = { max: 8, min: 3, targetCellPx: 72 }; @@ -161,3 +198,135 @@ describe('getGalleryGridRowIndexForItemKey', () => { expect(getGalleryGridRowIndexForItemKey(items, 'image:regular-3', 2)).toBe(1); }); }); + +describe('sparse gallery geometry', () => { + it('maps loaded sparse items to their absolute selection pages', () => { + const infiniteItem = createImageItem('infinite'); + const paginatedItem = createImageItem('paginated'); + + expect( + getGallerySparseSelectionPages({ + itemSlots: new Map([[180, infiniteItem]]), + pageOffset: 0, + }).get('image:infinite') + ).toBe(3); + expect( + getGallerySparseSelectionPages({ + itemSlots: new Map([[5, paginatedItem]]), + pageOffset: GALLERY_PAGE_SIZE * 2, + }).get('image:paginated') + ).toBe(2); + }); + + it('maps distant item positions to absolute rows and uses position-stable identities', () => { + const first = createImageItem('first'); + const third = createImageItem('third'); + const slots = new Map([ + [120, first], + [122, third], + ]); + + expect(getGallerySparseRowIndexForItemKey(slots, 'image:first', 2, 3)).toBe(63); + expect(getGallerySparseRowIndexForItemKey(slots, 'image:third', 2, 3)).toBe(64); + expect(getGallerySparseRowIndexForItemKey(slots, 'image:missing', 2, 3)).toBe(-1); + expect(getGallerySparseRowKey(60)).toBe('listing-row:60'); + expect(getGallerySparseSlotKey(122)).toBe('listing-slot:122'); + }); + + it('keeps keyboard row positions across page-local hydration holes', () => { + const first = createImageItem('first'); + const third = createImageItem('third'); + const entries = buildSparseGalleryNavigationEntries({ + itemSlots: new Map([ + [120, first], + [122, third], + ]), + pageOffsets: [120], + total: 130, + }); + + expect(entries).toHaveLength(11); + expect(entries[0]).toEqual({ id: 'gallery-unloaded-slot:119', kind: 'slot', navigable: true }); + expect(entries[1]).toEqual({ item: first, kind: 'item' }); + expect(entries[2]).toEqual({ id: 'gallery-gap-slot:121', kind: 'slot', navigable: false }); + expect(entries[3]).toEqual({ item: third, kind: 'item' }); + }); + + it('waits on the empty positions of a page that is still loading', () => { + const last = createImageItem('item-59'); + const entries = buildSparseGalleryNavigationEntries({ + itemSlots: new Map([[59, last]]), + pageOffsets: [0, 60], + pendingPageOffsets: new Set([60]), + total: 180, + }); + + expect(entries[59]).toEqual({ item: last, kind: 'item' }); + expect(entries[60]).toEqual({ id: 'gallery-unloaded-slot:60', kind: 'slot', navigable: true }); + // A settled page's empty position is a gap. + expect(entries[58]).toEqual({ id: 'gallery-gap-slot:58', kind: 'slot', navigable: false }); + }); + + it('covers only the runs of subscribed pages, not the positions between them', () => { + const first = createImageItem('first'); + const revealed = createImageItem('revealed'); + const entries = buildSparseGalleryNavigationEntries({ + columnCount: 2, + itemSlots: new Map([ + [0, first], + [600_000, revealed], + ]), + pageOffsets: [0, 600_000], + total: 700_000, + }); + + // Each page plus one boundary row on each side of the distant run, and one after the first. + expect(entries).toHaveLength(60 + 2 + 2 + 60 + 2); + expect(entries[0]).toEqual({ item: first, kind: 'item' }); + expect(entries[62]).toEqual({ id: 'gallery-unloaded-slot:599998', kind: 'slot', navigable: true }); + expect(entries[64]).toEqual({ item: revealed, kind: 'item' }); + }); + + it('ends a run on a whole row before the next one while the total is unknown', () => { + const entries = buildSparseGalleryNavigationEntries({ + columnCount: 7, + itemSlots: new Map(), + pageOffsets: [0, 600], + total: null, + }); + + // 60 positions round up to 63 so the next run's rows keep their columns. + expect(entries[62]).toEqual({ id: 'gallery-unloaded-slot:62', kind: 'slot', navigable: true }); + expect(entries[63]).toEqual({ id: 'gallery-unloaded-slot:588', kind: 'slot', navigable: true }); + }); + + it('joins runs whose boundary rows meet without repeating positions', () => { + // At 40 columns the first run's trailing row reaches 120 and the second run's leading row starts at 80. + const entries = buildSparseGalleryNavigationEntries({ + columnCount: 40, + itemSlots: new Map(), + pageOffsets: [0, 120], + total: 300, + }); + const indices = entries.map((entry) => Number((entry as { id: string }).id.split(':')[1])); + + expect(indices).toEqual(Array.from({ length: 240 }, (_, index) => index)); + }); + + it('includes a navigable preceding row before a distant active page', () => { + const first = createImageItem('first'); + const entries = buildSparseGalleryNavigationEntries({ + columnCount: 3, + itemSlots: new Map([[120, first]]), + pageOffsets: [120], + total: 600, + }); + + expect(entries.slice(0, 4)).toEqual([ + { id: 'gallery-unloaded-slot:117', kind: 'slot', navigable: true }, + { id: 'gallery-unloaded-slot:118', kind: 'slot', navigable: true }, + { id: 'gallery-unloaded-slot:119', kind: 'slot', navigable: true }, + { item: first, kind: 'item' }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts index 4effcd5d6a5..9f717e18f09 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts @@ -1,6 +1,47 @@ import type { GalleryItem, GalleryItemKey } from '@features/gallery/core/items'; +import type { GalleryNavigationEntry } from '@features/gallery/core/selection'; import { toGalleryItemKey } from '@features/gallery/core/items'; +import { GALLERY_PAGE_SIZE } from '@features/gallery/core/paging'; + +/** Plans aligned pages intersecting a half-open item range. A null total means listing size is not known yet. */ +export const planGalleryPageOffsets = ({ + endIndexExclusive, + startIndex, + total, +}: { + endIndexExclusive: number; + startIndex: number; + total: number | null; +}): number[] => { + if (!Number.isFinite(startIndex) || !Number.isFinite(endIndexExclusive)) { + return []; + } + + let firstIndex = Math.max(0, Math.floor(startIndex)); + let afterLastIndex = Math.max(0, Math.ceil(endIndexExclusive)); + + if (total !== null) { + const boundedTotal = Math.max(0, Math.floor(total)); + + firstIndex = Math.min(firstIndex, boundedTotal); + afterLastIndex = Math.min(afterLastIndex, boundedTotal); + } + + if (afterLastIndex <= firstIndex) { + return []; + } + + const firstOffset = Math.floor(firstIndex / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const lastOffset = Math.floor((afterLastIndex - 1) / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const offsets: number[] = []; + + for (let offset = firstOffset; offset <= lastOffset; offset += GALLERY_PAGE_SIZE) { + offsets.push(offset); + } + + return offsets; +}; export const GALLERY_GRID_GAP_PX = 4; /** The disclosure row of a pinned section (in progress, starred). */ @@ -118,6 +159,120 @@ export const getGalleryGridRowIndexForItemKey = ( return index < 0 ? -1 : Math.floor(index / columnCount); }; +/** Absolute backend slot to row; recent pseudo-rows are a separate prefix only for descending order. */ +export const getGallerySparseRowIndexForItemKey = ( + itemSlots: ReadonlyMap, + itemKey: GalleryItemKey, + columnCount: number, + leadingRows: number +): number => { + for (const [index, item] of itemSlots) { + if (toGalleryItemKey(item) === itemKey) { + return leadingRows + Math.floor(index / columnCount); + } + } + + return -1; +}; + +/** Resolves loaded sparse items to the backend page that owns their absolute ranking position. */ +export const getGallerySparseSelectionPages = ({ + itemSlots, + pageOffset, +}: { + itemSlots: ReadonlyMap; + pageOffset: number; +}): ReadonlyMap => { + const pages = new Map(); + + for (const [itemIndex, item] of itemSlots) { + pages.set(toGalleryItemKey(item), Math.floor((pageOffset + itemIndex) / GALLERY_PAGE_SIZE)); + } + + return pages; +}; + +/** Stable identities follow absolute listing positions while a page moves between loading, error, and ready. */ +export const getGallerySparseRowKey = (rowIndex: number): string => `listing-row:${rowIndex}`; +export const getGallerySparseSlotKey = (itemIndex: number): string => `listing-slot:${itemIndex}`; + +/** + * Include empty positions from active pages so arrow navigation retains real row and column geometry across + * hydration gaps. Entries cover each contiguous run of subscribed pages plus a boundary row, so a page retained far + * from the viewport (a reveal) adds its own run rather than every position between them. + */ +export const buildSparseGalleryNavigationEntries = ({ + columnCount = 1, + includeUnloadedBoundaries = true, + itemSlots, + pageOffsets, + pendingPageOffsets, + total, +}: { + columnCount?: number; + includeUnloadedBoundaries?: boolean; + itemSlots: ReadonlyMap; + pageOffsets: readonly number[]; + /** Active pages still fetching: their empty positions wait for data instead of being skipped as gaps. */ + pendingPageOffsets?: ReadonlySet; + total: number | null; +}): GalleryNavigationEntry[] => { + if (pageOffsets.length === 0) { + return []; + } + + const sortedOffsets = [...new Set(pageOffsets)].sort((left, right) => left - right); + const runs: { first: number; last: number }[] = []; + + for (const offset of sortedOffsets) { + const run = runs.at(-1); + + if (run && offset === run.last + GALLERY_PAGE_SIZE) { + run.last = offset; + } else { + runs.push({ first: offset, last: offset }); + } + } + + const activePages = new Set(sortedOffsets); + const entries: GalleryNavigationEntry[] = []; + let nextIndex = 0; + + for (const [runIndex, run] of runs.entries()) { + const activeStartIndex = Math.floor(run.first / columnCount) * columnCount; + const startIndex = + includeUnloadedBoundaries && run.first > 0 ? Math.max(0, activeStartIndex - columnCount) : activeStartIndex; + const activeEndIndex = Math.min(total ?? Number.POSITIVE_INFINITY, run.last + GALLERY_PAGE_SIZE); + const isFollowedByRun = runIndex < runs.length - 1; + // Navigation chunks entries into rows, so a run followed by another must end on a whole row. + const endIndex = + includeUnloadedBoundaries && total !== null + ? Math.min(total, Math.ceil(activeEndIndex / columnCount) * columnCount + columnCount) + : isFollowedByRun + ? Math.ceil(activeEndIndex / columnCount) * columnCount + : activeEndIndex; + + // Runs whose boundary rows meet continue without repeating positions. + for (let index = Math.max(startIndex, nextIndex); index < endIndex; index += 1) { + const item = itemSlots.get(index); + const pageOffset = Math.floor(index / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const isSettledActivePage = activePages.has(pageOffset) && !pendingPageOffsets?.has(pageOffset); + + entries.push( + item + ? { item, kind: 'item' } + : isSettledActivePage + ? { id: `gallery-gap-slot:${index}`, kind: 'slot', navigable: false } + : { id: `gallery-unloaded-slot:${index}`, kind: 'slot', navigable: true } + ); + } + + nextIndex = Math.max(nextIndex, endIndex); + } + + return entries; +}; + export const getGalleryProgressLayout = ({ columns, tileSize, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts index a52699fa65e..a4a71953080 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts @@ -250,6 +250,16 @@ describe('gallery state view', () => { expect(getGallerySelectedImageQuery({ starredOnly: true })).toMatchObject({ starredOnly: true }); }); + it('says whether the selection was made in a starred-only listing, by its stamp', () => { + const carriedIn = { selectedImageQuery: { starredOnly: false }, starredOnly: true }; + + expect(getGalleryStateView(carriedIn, boards, []).selectionStarredOnly).toBe(false); + expect( + getGalleryStateView({ selectedImageQuery: { starredOnly: true }, starredOnly: true }, boards, []) + .selectionStarredOnly + ).toBe(true); + }); + it('qualifies legacy names and preserves ordered mixed-media selection keys', () => { const gallery = getGalleryStateView( { selectedImageNames: ['a.png', 'video:shared', 'image:shared', 7] }, @@ -285,6 +295,9 @@ describe('gallery state view', () => { const values = { selectedImageName: 'image:starred.png' }; expect(getGalleryStateView(values, boards, [createImageItem('regular.png')]).selectedItemKey).toBeNull(); + expect(getGalleryStateView(values, boards, [createImageItem('regular.png')]).primarySelectedItemKey).toBe( + 'image:starred.png' + ); expect(getGalleryStateView(values, boards, [createImageItem('regular.png')], [starred]).selectedItemKey).toBe( 'image:starred.png' ); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts index f0e5228de0b..b8320bb2061 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.ts @@ -48,8 +48,15 @@ export interface GalleryStateView { revealTargetPage: number | null; searchTerm: string; selectedBoardId: string; + /** The persisted primary selection, retained while its sparse page is not loaded. */ + primarySelectedItemKey: GalleryItemKey | null; selectedItemKey: GalleryItemKey | null; selectedItemKeys: GalleryItemKey[]; + /** + * The selection was made in a starred-only listing, so its members were starred when selected. A selection carried + * in from another listing says nothing about the star flags of members no page has loaded. + */ + selectionStarredOnly: boolean; /** Active image-similarity query, rendered as a chip in place of the search text. */ semanticImageQuery: GallerySemanticReference | null; /** The semantic field's text while the field is in semantic mode; null in metadata mode. */ @@ -151,6 +158,10 @@ export interface GallerySelectedImageQuery { page: number; paginationMode: 'infinite' | 'paginated'; searchTerm: string; + /** The selection navigates its item's own board, unranked: one made outside the Gallery, such as a search pick. */ + itemBoard: boolean; + /** Ranking identity for a semantic result page; null for ordinary listings and legacy state. */ + semanticKey: string | null; starredOnly: boolean; } @@ -179,7 +190,9 @@ export const getGallerySelectedImageQuery = (values: Record): G query?.paginationMode === 'infinite' || query?.paginationMode === 'paginated' ? query.paginationMode : settings.paginationMode, + itemBoard: query?.itemBoard === true, searchTerm: query && typeof query.searchTerm === 'string' ? query.searchTerm : String(values.searchTerm ?? ''), + semanticKey: query && typeof query.semanticKey === 'string' && query.semanticKey ? query.semanticKey : null, starredOnly: query && typeof query.starredOnly === 'boolean' ? query.starredOnly : getGalleryStarredOnly(values), }; }; @@ -367,11 +380,13 @@ export const getGalleryStateView = ( revealTargetPage, searchTerm, selectedBoardId, + primarySelectedItemKey: persistedSelectedItemKey, selectedItemKey: visibleSelectedItemKey, selectedItemKeys: visibleSelectedItemKey && !selectedItemKeys.includes(visibleSelectedItemKey) ? [visibleSelectedItemKey, ...selectedItemKeys] : selectedItemKeys, + selectionStarredOnly: selectedImageQuery.starredOnly, semanticImageQuery, semanticSearchText: getGallerySemanticSearchText(values), settings, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryMediaSlot.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryMediaSlot.browser.test.tsx index 08c6667915a..8410b3dadbd 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryMediaSlot.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryMediaSlot.browser.test.tsx @@ -51,6 +51,11 @@ vi.mock('@features/gallery/data/queries', async (importOriginal) => ({ queryKey: ['test-slot-items'], staleTime: Infinity, }), + galleryItemsPageOptions: (filter: unknown, offset: number) => ({ + queryFn: () => Promise.resolve({ items: [image('a.png')], itemIndices: [offset], offset, total: 1 }), + queryKey: ['gallery', 'items', 'list', { accountId: 'test-account', epoch: 0 }, filter, 'page', offset], + staleTime: Infinity, + }), })); vi.mock('@features/gallery/data/backend', async (importOriginal) => ({ diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerGrid.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerGrid.tsx index ed2b5edd305..61ce14c16a8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerGrid.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerGrid.tsx @@ -1,14 +1,18 @@ import type { SystemStyleObject } from '@chakra-ui/react'; import type { GalleryItem, GalleryItemKey } from '@features/gallery/core/items'; +import type { GallerySparsePageState } from '@features/gallery/ui/useGalleryData'; import type { CSSProperties, MouseEvent } from 'react'; -import { Box, Icon, Skeleton } from '@chakra-ui/react'; +import { Box, Icon, Skeleton, Stack, Text } from '@chakra-ui/react'; import { toGalleryItemKey } from '@features/gallery/core/items'; +import { GALLERY_PAGE_SIZE } from '@features/gallery/data/queries'; import { getGalleryColumnCountForCell } from '@features/gallery/ui/galleryGridLayout'; import { GalleryTileFrame } from '@features/gallery/ui/GalleryTileFrame'; +import { Button } from '@platform/ui/Button'; import { Scrollable } from '@platform/ui/Scrollable'; import { CheckIcon } from 'lucide-react'; -import { memo, useCallback, useMemo, useRef } from 'react'; +import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from 'react-hook-tanstack-virtual'; import { useTranslation } from 'react-i18next'; import { @@ -19,6 +23,7 @@ import { } from './galleryPicker'; const GRID_GAP_PX = 4; +const GRID_PADDING_PX = 8; const SKELETON_TILE_COUNT = 8; const IMG_STYLE: CSSProperties = { @@ -46,55 +51,47 @@ const getTileCss = (state: GalleryPickerTileState, isActive: boolean): SystemSty return isActive ? { ...base, ...ACTIVE_TILE_CSS } : base; }; -export const galleryPickerOptionId = (idBase: string, key: GalleryItemKey): string => `${idBase}-${key}`; +export const galleryPickerOptionId = (idBase: string, index: number): string => `${idBase}-slot-${index}`; const GalleryPickerTile = memo(function GalleryPickerTile({ + activeIndex, + currentKey, idBase, - isActive, - isCurrent, + index, isMultiple, item, state, + total, }: { + activeIndex: number; + currentKey: GalleryItemKey | null; idBase: string; - isActive: boolean; - /** The Gallery widget's own selection, ringed so it reads as the default. */ - isCurrent: boolean; + index: number; isMultiple: boolean; item: GalleryItem; state: GalleryPickerTileState; + total: number; }) { const { t } = useTranslation(); const key = toGalleryItemKey(item); - const css = useMemo(() => getTileCss(state, isActive), [isActive, state]); + const css = useMemo(() => getTileCss(state, index === activeIndex), [activeIndex, index, state]); const unsupportedLabel = state === 'unsupported' ? t(item.kind === 'video' ? 'widgets.gallery.picker.unsupportedVideo' : 'widgets.gallery.picker.unsupportedImage') : undefined; - // Active-state ref callbacks reveal keyboard highlights; pointer hover must not scroll. - const scrollIntoView = useCallback( - (node: HTMLDivElement | null) => { - if (node && isActive) { - node.scrollIntoView({ block: 'nearest' }); - } - }, - [isActive] - ); - return ( Promise; + total: number; +}) => { + const { t } = useTranslation(); + const handleRetry = useCallback(() => { + if (retry) { + void retry(); + } + }, [retry]); + + return ( + + {error && retry ? ( + + + {error.message} + + + + ) : isLoading ? ( + + ) : null} + + ); +}; + +/** A virtualized absolute-slot picker backed by the same 60-item Query pages as Gallery. */ export const GalleryPickerGrid = ({ - activeKey, + activeIndex, + activePlacement, columnCount, currentKey, getTileState, idBase, isMultiple, isStale, - items, + itemSlots, label, onActivate, onColumnCountChange, - onLoadMore, + onVisibleRangeChange, + pageStates, + suppressInlineRetry, + total, }: { - activeKey: GalleryItemKey | null; + activeIndex: number; + /** + * The highlight's last deliberate placement, in its listing. A new placement reveals the highlight even at the same + * index; an insert that only shifts the highlighted item's index does not. + */ + activePlacement: string; columnCount: number; currentKey: GalleryItemKey | null; getTileState: (item: GalleryItem) => GalleryPickerTileState; idBase: string; isMultiple: boolean; - /** `items` belong to the previous scope while the current one loads. */ + /** Slots belong to the previous scope while the current listing loads. */ isStale: boolean; - /** Null while nothing has loaded yet. */ - items: GalleryItem[] | null; + itemSlots: ReadonlyMap; label: string; onActivate: (item: GalleryItem) => void; onColumnCountChange: (columnCount: number) => void; - onLoadMore: () => void; + onVisibleRangeChange: (range: { endIndexExclusive: number; startIndex: number }) => void; + pageStates: ReadonlyMap; + suppressInlineRetry: boolean; + total: number | null; }) => { const resizeObserverRef = useRef(null); - const loadMoreObserverRef = useRef(null); - const itemsByKey = useMemo(() => new Map(items?.map((item) => [toGalleryItemKey(item), item])), [items]); + const viewportRef = useRef(null); + const [viewportWidth, setViewportWidth] = useState(0); + const totalSlots = total ?? SKELETON_TILE_COUNT; + const rowCount = Math.ceil(totalSlots / columnCount); + const rowPitch = + viewportWidth > 0 + ? Math.max(1, (viewportWidth - GRID_PADDING_PX * 2 - GRID_GAP_PX * (columnCount - 1)) / columnCount) + GRID_GAP_PX + : GALLERY_PICKER_CELL_PX + GRID_GAP_PX; + const itemsByKey = useMemo( + () => new Map([...itemSlots.values()].map((item) => [toGalleryItemKey(item), item])), + [itemSlots] + ); const measureRef = useCallback( (node: HTMLDivElement | null) => { @@ -179,6 +256,7 @@ export const GalleryPickerGrid = ({ const widthPx = entry?.contentRect.width ?? 0; if (widthPx > 0) { + setViewportWidth(widthPx); onColumnCountChange( getGalleryColumnCountForCell({ max: GALLERY_PICKER_MAX_COLUMNS, @@ -196,31 +274,59 @@ export const GalleryPickerGrid = ({ [onColumnCountChange] ); - // The scroll viewport is found from the DOM rather than a ref: the sentinel - // can mount in the same commit as the viewport, before any parent ref is set. - const sentinelRef = useCallback( - (node: HTMLDivElement | null) => { - loadMoreObserverRef.current?.disconnect(); - loadMoreObserverRef.current = null; + const getScrollElement = useCallback(() => viewportRef.current, []); + const getItemKey = useCallback((index: number) => index, []); + const estimateSize = useCallback(() => rowPitch, [rowPitch]); + const handleVirtualizerChange = useCallback( + (instance: { getVirtualItems: () => readonly { index: number }[] }) => { + if (total === 0) { + return; + } - if (!node) { + const visibleRows = instance.getVirtualItems(); + const firstRow = visibleRows[0]?.index; + const lastRow = visibleRows[visibleRows.length - 1]?.index; + + if (firstRow === undefined || lastRow === undefined) { return; } - const observer = new IntersectionObserver( - ([entry]) => { - if (entry?.isIntersecting) { - onLoadMore(); - } - }, - { root: node.closest('[data-scope="scroll-area"][data-part="viewport"]'), rootMargin: '160px' } - ); + const startIndex = Math.min(total ?? GALLERY_PAGE_SIZE, firstRow * columnCount); + const endIndexExclusive = Math.min(total ?? GALLERY_PAGE_SIZE, (lastRow + 1) * columnCount); - observer.observe(node); - loadMoreObserverRef.current = observer; + onVisibleRangeChange({ endIndexExclusive, startIndex }); }, - [onLoadMore] + [columnCount, onVisibleRangeChange, total] ); + const virtualizer = useVirtualizer({ + count: rowCount, + estimateSize, + getItemKey, + getScrollElement, + onChange: handleVirtualizerChange, + overscan: 2, + useFlushSync: false, + }); + const { measure, scrollToIndex, totalSize, virtualItems } = virtualizer; + const isInitialPageFailed = pageStates.get(0)?.error !== null && pageStates.get(0)?.error !== undefined; + const isAnyPageLoading = [...pageStates.values()].some((pageState) => pageState.isLoading); + + useLayoutEffect(() => { + measure(); + }, [columnCount, measure, rowPitch]); + + // Reveal the highlight when it is placed, its listing changes, or a reflow moves its row. A later count update or + // insert must not pull a pointer-scrolled view back to it; a highlight past the known count is revealed once the + // count reaches it. + const revealedActiveRef = useRef(null); + useLayoutEffect(() => { + const active = `${activePlacement}\n${columnCount}`; + + if (activeIndex >= 0 && activeIndex < totalSlots && revealedActiveRef.current !== active) { + revealedActiveRef.current = active; + scrollToIndex(Math.floor(activeIndex / columnCount), { align: 'auto' }); + } + }, [activeIndex, activePlacement, columnCount, scrollToIndex, totalSlots]); const handleClick = useCallback( (event: MouseEvent) => { @@ -240,43 +346,84 @@ export const GalleryPickerGrid = ({ ); return ( - + - {items === null - ? Array.from({ length: SKELETON_TILE_COUNT }, (_, index) => ( - - )) - : items.map((item) => { - const key = toGalleryItemKey(item); - - return ( - - ); - })} + {virtualItems.map((virtualRow) => { + const startIndex = virtualRow.index * columnCount; + const endIndex = Math.min(totalSlots, startIndex + columnCount); + const cells = Array.from({ length: endIndex - startIndex }, (_, offset) => startIndex + offset); + + return ( + + {cells.map((index) => { + const item = itemSlots.get(index); + + if (item) { + return ( + + ); + } + + const pageOffset = Math.floor(index / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const pageState = pageStates.get(pageOffset); + const hasError = pageState?.error !== null && pageState?.error !== undefined; + const isRetrySlot = index === pageOffset && hasError; + + return ( + + ); + })} + + ); + })} - {items && items.length > 0 && !isStale ? ); }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx index 10ed6047b3b..3f30195228a 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerPopover.browser.test.tsx @@ -20,6 +20,7 @@ import { GalleryPickerPopover } from './GalleryPickerPopover'; const mocks = vi.hoisted(() => ({ invalidateGallery: vi.fn(), listItems: vi.fn(), + listInfiniteItems: vi.fn(), uploadGalleryImage: vi.fn(), uploadGalleryVideo: vi.fn(), })); @@ -47,10 +48,15 @@ vi.mock('@features/gallery/data/queries', async (importOriginal) => ({ galleryItemsInfiniteOptions: (filter: { boardId: string; galleryView: string; searchTerm: string }) => ({ getNextPageParam: () => undefined, initialPageParam: 0, - queryFn: () => Promise.resolve(mocks.listItems(filter)), + queryFn: () => Promise.resolve(mocks.listInfiniteItems(filter)), queryKey: ['test-picker-items', filter.boardId, filter.galleryView, filter.searchTerm], staleTime: Infinity, }), + galleryItemsPageOptions: (filter: { boardId: string; galleryView: string; searchTerm: string }, offset: number) => ({ + queryFn: () => Promise.resolve(mocks.listItems({ ...filter, offset })), + queryKey: ['gallery', 'items', 'list', { accountId: 'test-account', epoch: 0 }, filter, 'page', offset], + staleTime: Infinity, + }), })); vi.mock('@features/gallery/data/backend', async (importOriginal) => ({ @@ -241,8 +247,12 @@ const getBoardRow = (dialog: HTMLElement, text: string) => [...(getBoardsRegion(dialog)?.querySelectorAll('button') ?? [])].find((row) => row.textContent?.includes(text) ); -const getColumnCount = (dialog: HTMLElement) => - getComputedStyle(dialog.querySelector('[role="listbox"]')!).gridTemplateColumns.split(' ').length; +const getColumnCount = (dialog: HTMLElement) => { + const listbox = dialog.querySelector('[role="listbox"]'); + const firstVirtualRow = listbox?.firstElementChild; + + return getComputedStyle(firstVirtualRow ?? listbox!).gridTemplateColumns.split(' ').length; +}; const pressKey = async (target: HTMLElement, key: string) => { await act(() => target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key }))); @@ -261,11 +271,17 @@ const typeSearch = async (input: HTMLInputElement, value: string) => { beforeEach(() => { vi.clearAllMocks(); - mocks.listItems.mockImplementation((filter: { boardId: string; searchTerm: string }) => { + mocks.listItems.mockImplementation((filter: { boardId: string; offset: number; searchTerm: string }) => { const items = filter.boardId === 'none' ? uncategorizedItems : dogItems; const matching = items.filter((item) => item.name.includes(filter.searchTerm)); + const pageItems = matching.slice(filter.offset, filter.offset + 60); - return { items: matching, total: matching.length }; + return { + itemIndices: pageItems.map((_, index) => filter.offset + index), + items: pageItems, + offset: filter.offset, + total: matching.length, + }; }); host = document.createElement('div'); document.body.appendChild(host); @@ -333,6 +349,331 @@ describe('GalleryPickerPopover', () => { expect(document.querySelector(OPEN_DIALOG)).toBeNull(); }); + it('keeps the highlight on its item when an insert shifts the listing', async () => { + const { dialog } = await openPicker(); + + expect(getActiveOption(dialog)).toBe(getOption(dialog, 'image:b.png')); + + // Another client adds a newer image, so the refetched listing moves every item one slot along. + dogItems.unshift(image('new.png')); + try { + await act(() => queryClient!.invalidateQueries()); + await vi.waitFor(() => expect(getOption(dialog, 'image:new.png')).toBeDefined()); + await settle(); + + expect(getActiveOption(dialog)).toBe(getOption(dialog, 'image:b.png')); + + await pressKey(getSearchInput(dialog), 'Enter'); + expect(onPick).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'b.png' })); + } finally { + dogItems.shift(); + } + }); + + it('requests a distant keyboard target by page offset without fetching intervening pages', async () => { + const total = 2_400; + const lastPageOffset = Math.floor((total - 1) / 60) * 60; + + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const items = Array.from({ length: Math.min(60, total - filter.offset) }, (_, index) => + image(`distant-${filter.offset + index}.png`) + ); + + return { + itemIndices: items.map((_, index) => filter.offset + index), + items, + offset: filter.offset, + total, + }; + }); + + const { dialog } = await openPicker(); + const input = getSearchInput(dialog); + + await pressKey(input, 'End'); + await vi.waitFor(() => expect(getActiveOption(dialog)?.getAttribute('aria-posinset')).toBe(String(total))); + + const requestedOffsets = mocks.listItems.mock.calls.map(([filter]) => filter.offset as number); + + expect(requestedOffsets).toContain(0); + expect(requestedOffsets).toContain(lastPageOffset); + expect(requestedOffsets.every((offset) => offset === 0 || offset >= lastPageOffset - 60)).toBe(true); + expect(mocks.listInfiniteItems).not.toHaveBeenCalled(); + }); + + it('holds the item a placeholder highlight lands on once its page loads, through a later insert', async () => { + let inserted = 0; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const total = 2_390 + inserted; + const items = Array.from({ length: Math.max(0, Math.min(60, total - filter.offset)) }, (_, index) => { + const position = filter.offset + index - inserted; + + return image(position < 0 ? 'adopted-new.png' : `adopted-${position}.png`); + }); + + return { itemIndices: items.map((_, index) => filter.offset + index), items, offset: filter.offset, total }; + }); + + const { dialog } = await openPicker(); + const input = getSearchInput(dialog); + + // End lands on a placeholder: its page is requested only now. + await pressKey(input, 'End'); + await vi.waitFor(() => expect(getActiveOption(dialog)?.dataset.itemKey).toBe('image:adopted-2389.png')); + + inserted = 1; + await act(() => queryClient!.invalidateQueries()); + await vi.waitFor(() => expect(dialog.querySelector('[role="option"][aria-setsize="2391"]')).not.toBeNull()); + await settle(); + + expect(getActiveOption(dialog)?.dataset.itemKey).toBe('image:adopted-2389.png'); + await pressKey(input, 'Enter'); + expect(onPick).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ name: 'adopted-2389.png' })); + }); + + it('keeps both pages of a view that straddles a page edge loaded while the arrows move within it', async () => { + const total = 120; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const items = Array.from({ length: Math.max(0, Math.min(60, total - filter.offset)) }, (_, index) => + image(`edge-${filter.offset + index}.png`) + ); + + return { itemIndices: items.map((_, index) => filter.offset + index), items, offset: filter.offset, total }; + }); + + const { dialog } = await openPicker(); + const input = getSearchInput(dialog); + const activeIndex = () => Number(getActiveOption(dialog)?.dataset.itemIndex ?? -1); + const columns = getColumnCount(dialog); + + await pressKey(input, 'End'); + await vi.waitFor(() => expect(activeIndex()).toBe(total - 1)); + + // Walk up into the first page: the row just left behind, on the second page, stays in view. + while (activeIndex() >= 60) { + await pressKey(input, 'ArrowUp'); + } + const belowIndex = activeIndex() + columns; + + expect(belowIndex).toBeGreaterThanOrEqual(60); + // Record every state the tile passes through: dropping its page would flash a skeleton before it re-subscribes. + const belowKey = `image:edge-${belowIndex}.png`; + const tileKeys = new Set(); + const observer = new MutationObserver(() => { + tileKeys.add(dialog.querySelector(`[data-item-index="${belowIndex}"]`)?.getAttribute('data-item-key')); + }); + observer.observe(dialog, { attributes: true, childList: true, subtree: true }); + await pressKey(input, 'ArrowLeft'); + observer.disconnect(); + + expect(activeIndex()).toBe(belowIndex - columns - 1); + expect([...tileKeys].filter((key) => key !== belowKey)).toEqual([]); + expect(dialog.querySelector(`[data-item-index="${belowIndex}"]`)?.getAttribute('data-item-key')).toBe(belowKey); + }); + + it('keeps a pointer-scrolled position when a later page changes the count', async () => { + let total = 600; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const items = Array.from({ length: Math.max(0, Math.min(60, total - filter.offset)) }, (_, index) => + image(`scrolled-${filter.offset + index}.png`) + ); + + return { itemIndices: items.map((_, index) => filter.offset + index), items, offset: filter.offset, total }; + }); + + const { dialog } = await openPicker(); + const firstOption = await vi.waitFor(() => { + const option = dialog.querySelector('[role="option"]'); + + expect(option).not.toBeNull(); + return option!; + }); + let viewport: HTMLElement | null = firstOption.parentElement; + + while (viewport && viewport.scrollHeight <= viewport.clientHeight) { + viewport = viewport.parentElement; + } + + expect(viewport).not.toBeNull(); + // Another client adds an item, so pages fetched after the scroll report a larger count. + total = 601; + await act(() => { + viewport!.scrollTop = viewport!.scrollHeight / 2; + viewport!.dispatchEvent(new Event('scroll')); + }); + const scrolledTop = viewport!.scrollTop; + + expect(scrolledTop).toBeGreaterThan(0); + await vi.waitFor(() => expect(dialog.querySelector('[role="option"][aria-setsize="601"]')).not.toBeNull()); + await settle(); + + expect(viewport!.scrollTop).toBe(scrolledTop); + }); + + it('keeps a pointer-scrolled position when an insert shifts the highlighted item', async () => { + let inserted = 0; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const total = 600 + inserted; + const items = Array.from({ length: Math.max(0, Math.min(60, total - filter.offset)) }, (_, index) => { + const position = filter.offset + index - inserted; + + return image(position < 0 ? 'shifted-new.png' : `shifted-${position}.png`); + }); + + return { itemIndices: items.map((_, index) => filter.offset + index), items, offset: filter.offset, total }; + }); + + const { dialog } = await openPicker(); + + expect(getActiveOption(dialog)?.dataset.itemKey).toBe('image:shifted-0.png'); + let viewport: HTMLElement | null = getActiveOption(dialog)!.parentElement; + + while (viewport && viewport.scrollHeight <= viewport.clientHeight) { + viewport = viewport.parentElement; + } + + // Scroll the highlighted first row out of view while its page stays loaded. + await act(() => { + viewport!.scrollTop = getActiveOption(dialog)!.getBoundingClientRect().height * 4; + viewport!.dispatchEvent(new Event('scroll')); + }); + await settle(); + const scrolledTop = viewport!.scrollTop; + + expect(scrolledTop).toBeGreaterThan(0); + inserted = 1; + await act(() => queryClient!.invalidateQueries()); + await vi.waitFor(() => expect(dialog.querySelector('[role="option"][aria-setsize="601"]')).not.toBeNull()); + await settle(); + + // The highlight moved one slot along with its item, which stays scrolled out of view. + expect(getSearchInput(dialog).getAttribute('aria-activedescendant')).toMatch(/-slot-1$/); + expect(viewport!.scrollTop).toBe(scrolledTop); + }); + + it('reveals an arrow move back onto the slot the highlight was first placed on before an insert shifted it', async () => { + let inserted = 0; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const total = 600 + inserted; + const items = Array.from({ length: Math.max(0, Math.min(60, total - filter.offset)) }, (_, index) => { + const position = filter.offset + index - inserted; + + return image(position < 0 ? 'moved-new.png' : `moved-${position}.png`); + }); + + return { itemIndices: items.map((_, index) => filter.offset + index), items, offset: filter.offset, total }; + }); + + const { dialog } = await openPicker(); + let viewport: HTMLElement | null = getActiveOption(dialog)!.parentElement; + + while (viewport && viewport.scrollHeight <= viewport.clientHeight) { + viewport = viewport.parentElement; + } + + inserted = 1; + await act(() => queryClient!.invalidateQueries()); + await vi.waitFor(() => expect(dialog.querySelector('[role="option"][aria-setsize="601"]')).not.toBeNull()); + await settle(); + expect(getActiveOption(dialog)?.dataset.itemKey).toBe('image:moved-0.png'); + + // Scroll the highlight out of view, then step back onto slot 0, where it was placed before the insert. + await act(() => { + viewport!.scrollTop = getActiveOption(dialog)!.getBoundingClientRect().height * 4; + viewport!.dispatchEvent(new Event('scroll')); + }); + await settle(); + expect(viewport!.scrollTop).toBeGreaterThan(0); + + await pressKey(getSearchInput(dialog), 'ArrowLeft'); + await settle(); + + expect(getActiveOption(dialog)?.dataset.itemKey).toBe('image:moved-new.png'); + expect(viewport!.scrollTop).toBe(0); + }); + + it('keeps the highlight in view when the column count changes', async () => { + const total = 60; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + const items = Array.from({ length: Math.max(0, Math.min(60, total - filter.offset)) }, (_, index) => + image(`reflow-${filter.offset + index}.png`) + ); + + return { itemIndices: items.map((_, index) => filter.offset + index), items, offset: filter.offset, total }; + }); + + const { dialog } = await openPicker(); + + await pressKey(getSearchInput(dialog), 'End'); + await vi.waitFor(() => expect(getActiveOption(dialog)?.getAttribute('aria-posinset')).toBe(String(total))); + const columnsBefore = getComputedStyle(getActiveOption(dialog)!.parentElement!).gridTemplateColumns; + + // Narrowing the popover drops a column, which moves the highlight's row further down. + await act(() => { + dialog.style.width = `${dialog.getBoundingClientRect().width * 0.7}px`; + }); + await vi.waitFor(() => + expect(getComputedStyle(getActiveOption(dialog)!.parentElement!).gridTemplateColumns).not.toBe(columnsBefore) + ); + await settle(); + + const option = getActiveOption(dialog)!.getBoundingClientRect(); + let viewport: HTMLElement | null = getActiveOption(dialog)!.parentElement; + + while (viewport && viewport.scrollHeight <= viewport.clientHeight) { + viewport = viewport.parentElement; + } + const bounds = viewport!.getBoundingClientRect(); + + expect(option.top).toBeGreaterThanOrEqual(bounds.top - 1); + expect(option.bottom).toBeLessThanOrEqual(bounds.bottom + 1); + }); + + it('shows and retries a failed uncached page within the current listing', async () => { + const total = 120; + let failedSecondPage = false; + mocks.listItems.mockImplementation((filter: { offset: number }) => { + if (filter.offset === 60 && !failedSecondPage) { + failedSecondPage = true; + return Promise.reject(new Error('page request failed')); + } + + const items = Array.from({ length: Math.min(60, total - filter.offset) }, (_, index) => + image(`page-${filter.offset + index}.png`) + ); + + return { + itemIndices: items.map((_, index) => filter.offset + index), + items, + offset: filter.offset, + total, + }; + }); + + const { dialog } = await openPicker(); + const input = getSearchInput(dialog); + + await pressKey(input, 'End'); + await vi.waitFor(() => + expect(mocks.listItems.mock.calls.filter(([filter]) => filter.offset === 60)).toHaveLength(1) + ); + const retry = await vi.waitFor(() => { + const button = [...dialog.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes('common.retry') + ); + + expect(button).toBeDefined(); + return button; + }); + expect(retry).toBeDefined(); + const retryBounds = retry!.getBoundingClientRect(); + const dialogBounds = dialog.getBoundingClientRect(); + expect(retryBounds.top).toBeGreaterThanOrEqual(dialogBounds.top); + expect(retryBounds.bottom).toBeLessThanOrEqual(dialogBounds.bottom); + await act(() => retry!.click()); + expect(mocks.listItems.mock.calls.filter(([filter]) => filter.offset === 60)).toHaveLength(2); + }); + it('closes on Escape and returns focus to the trigger', async () => { const { dialog, trigger } = await openPicker(); @@ -377,6 +718,44 @@ describe('GalleryPickerPopover', () => { expect(getActiveOption(dialog)).toBe(getOption(dialog, 'image:cat.png')); }); + it('does not let Enter pick stale slots while a different board page is loading', async () => { + let resolveCats: + | ((page: { items: GalleryItem[]; itemIndices: number[]; offset: number; total: number }) => void) + | null = null; + + mocks.listItems.mockImplementation((filter: { boardId: string; offset: number; searchTerm: string }) => { + if (filter.boardId === 'cats') { + return new Promise((resolve) => { + resolveCats = resolve; + }); + } + + return { + itemIndices: dogItems.map((_, index) => index), + items: dogItems, + offset: filter.offset, + total: dogItems.length, + }; + }); + + const { dialog } = await openPicker(); + const input = getSearchInput(dialog); + + await act(() => dialog.querySelector('[aria-expanded]')?.click()); + await settle(); + await act(() => getBoardRow(dialog, 'Cats')?.click()); + await vi.waitFor(() => expect(mocks.listItems).toHaveBeenCalledWith(expect.objectContaining({ boardId: 'cats' }))); + + await pressKey(input, 'Enter'); + + expect(onPick).not.toHaveBeenCalled(); + expect(getOptions(dialog).some((option) => option.dataset.itemKey === 'image:b.png')).toBe(true); + + await act(() => resolveCats?.({ items: [image('cat.png', 'cats')], itemIndices: [0], offset: 0, total: 1 })); + await settle(); + await vi.waitFor(() => expect(getOption(dialog, 'image:cat.png')).toBeDefined()); + }); + it('switches to the Assets view from the tabs', async () => { const { dialog } = await openPicker(); // Icons label the tabs in the picker's narrow header; the view names stay their accessible names. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx index bd36636d746..f6c55f4a624 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/GalleryPickerView.tsx @@ -1,9 +1,11 @@ import type { GalleryItem, GalleryItemKey } from '@features/gallery/core/items'; +import type { GallerySparseListing } from '@features/gallery/ui/useGalleryData'; import type { KeyboardEvent, RefObject } from 'react'; import { Box, HStack, Icon, Stack, Text } from '@chakra-ui/react'; import { getGalleryBoardLabel } from '@features/gallery/core/boardLabels'; import { getGalleryUploadAccept, toGalleryItemKey } from '@features/gallery/core/items'; +import { GALLERY_PAGE_SIZE, galleryItemsPageOptions } from '@features/gallery/data/queries'; import { BoardCover, BoardCoverIcon } from '@features/gallery/ui/GalleryBoardCover'; import { getGalleryBoardGroups } from '@features/gallery/ui/galleryBoardGroups'; import { GalleryAnnouncer, GalleryLoadErrorState, GalleryLoadNotice } from '@features/gallery/ui/GalleryLoadError'; @@ -26,7 +28,6 @@ import { useTranslation } from 'react-i18next'; import { GALLERY_PICKER_MIN_COLUMNS, - getGalleryPickerDefaultIndex, getGalleryPickerNeighborIndex, getGalleryPickerRemaining, getGalleryPickerSelectionAfterPick, @@ -44,8 +45,22 @@ import { useGalleryPickerScope } from './useGalleryPickerScope'; const BOARD_BUTTON_EXPANDED_PROPS = { bg: 'bg.hover' } as const; -/** Unseeded until the first page loads; then the Gallery's selection if it is on that page, else nothing. */ -type ActiveKeyState = GalleryItemKey | null | undefined; +const EMPTY_SLOTS: ReadonlyMap = new Map(); +const EMPTY_PAGE_STATES: GallerySparseListing['pageStates'] = new Map(); + +const haveSameSlots = (left: GallerySparseListing | null, right: GallerySparseListing): boolean => { + if (left?.total !== right.total || left.itemSlots.size !== right.itemSlots.size) { + return false; + } + + for (const [index, item] of right.itemSlots) { + if (left.itemSlots.get(index) !== item) { + return false; + } + } + + return true; +}; export const GalleryPickerView = ({ accept, @@ -72,39 +87,99 @@ export const GalleryPickerView = ({ const currentKey = gallerySelectedItem ? toGalleryItemKey(gallerySelectedItem) : null; const seedKey = gallerySelectedItem && accept.includes(gallerySelectedItem.kind) ? currentKey : null; - const [activeKey, setActiveKey] = useState(undefined); + // The highlight follows its item's key when an insert shifts it; only a placeholder is held by index. Each + // deliberate placement takes a new `placement`, so it reveals even where an earlier one rested. + const [activeCursor, setActiveCursor] = useState<{ + filterIdentity: string; + index: number; + key: GalleryItemKey | null; + placement: number; + } | null>(null); + const placeActiveCursor = useCallback( + (filterIdentity: string, index: number, key: GalleryItemKey | null) => + setActiveCursor((current) => ({ filterIdentity, index, key, placement: (current?.placement ?? 0) + 1 })), + [] + ); const [columnCount, setColumnCount] = useState(GALLERY_PICKER_MIN_COLUMNS); const [isUploading, setIsUploading] = useState(false); - // Async uploads need current selection capacity; the sentinel needs a stable callback across page fetches. + // Async uploads need current selection capacity. const selectionRef = useRef(selection); - const loadMoreRef = useRef(data.loadMore); + // Keep prior absolute slots dimmed during scope changes; a new scope starts at its own page zero. + const [lastListing, setLastListing] = useState<{ filterIdentity: string; listing: GallerySparseListing } | null>( + null + ); // eslint-disable-next-line react/refs selectionRef.current = selection; - // eslint-disable-next-line react/refs - loadMoreRef.current = data.loadMore; - - // The previous scope's items stay dimmed while the new scope loads; skeletons show only before any result. A - // failed scope shows its failure, never the previous scope's items. - const { listing } = data; - const isStale = data.items === null && data.previousScopeItems !== null; - const items = data.items ?? data.previousScopeItems; - - // Seed once the first page is in, so a selection past it never yanks the - // highlight (and the scroll) when a later page happens to contain it. - if (activeKey === undefined && data.items !== null) { - setActiveKey(seedKey && data.items.some((item) => toGalleryItemKey(item) === seedKey) ? seedKey : null); + + const filterIdentity = JSON.stringify(galleryItemsPageOptions(data.filter, 0).queryKey.slice(3, 5)); + if ( + data.items !== null && + data.sparseListing && + (lastListing?.filterIdentity !== filterIdentity || !haveSameSlots(lastListing.listing, data.sparseListing)) + ) { + setLastListing({ filterIdentity, listing: data.sparseListing }); + } + + const knownTotal = data.total; + const isStale = + data.listing.status === 'loading' && + data.items === null && + lastListing !== null && + lastListing.filterIdentity !== filterIdentity; + const sparseListing = isStale ? lastListing?.listing : data.sparseListing; + const itemSlots = sparseListing?.itemSlots ?? EMPTY_SLOTS; + const pageStates = isStale ? EMPTY_PAGE_STATES : (sparseListing?.pageStates ?? EMPTY_PAGE_STATES); + const total = sparseListing?.total ?? knownTotal; + const totalSlots = total ?? GALLERY_PAGE_SIZE; + const activeFilterCursor = activeCursor?.filterIdentity === filterIdentity ? activeCursor : null; + const setVisibleRange = data.setVisibleRange; + const slotIndexByKey = useMemo( + () => new Map([...itemSlots].map(([index, item]) => [toGalleryItemKey(item), index])), + [itemSlots] + ); + const getSlotKey = useCallback( + (index: number): GalleryItemKey | null => { + const item = itemSlots.get(index); + + return item ? toGalleryItemKey(item) : null; + }, + [itemSlots] + ); + + const defaultActiveIndex = useMemo(() => { + const slots = [...itemSlots].sort(([left], [right]) => left - right); + const firstPickable = slots.find(([, item]) => getGalleryPickerTileState(item, accept, selection) === 'pickable'); + + return firstPickable?.[0] ?? slots[0]?.[0] ?? -1; + }, [accept, itemSlots, selection]); + + // Seed once page zero lands. A selection elsewhere in the listing must not pull the picker away from its start. + if (activeFilterCursor === null && data.items !== null) { + const seededIndex = seedKey + ? [...itemSlots].find(([index, item]) => index < GALLERY_PAGE_SIZE && toGalleryItemKey(item) === seedKey)?.[0] + : undefined; + + const index = seededIndex ?? defaultActiveIndex; + + placeActiveCursor(filterIdentity, index, getSlotKey(index)); + } else if (activeFilterCursor?.key === null && itemSlots.has(activeFilterCursor.index)) { + // A placeholder the highlight rests on has loaded: hold its item from now on. + setActiveCursor({ ...activeFilterCursor, key: getSlotKey(activeFilterCursor.index) }); } - const activeIndex = activeKey && items ? items.findIndex((item) => toGalleryItemKey(item) === activeKey) : -1; const resolvedActiveIndex = - activeIndex >= 0 ? activeIndex : items ? getGalleryPickerDefaultIndex(items, accept, selection) : -1; - const activeItem = resolvedActiveIndex >= 0 ? items?.[resolvedActiveIndex] : undefined; - const resolvedActiveKey = activeItem ? toGalleryItemKey(activeItem) : null; + activeFilterCursor === null + ? defaultActiveIndex + : ((activeFilterCursor.key === null ? undefined : slotIndexByKey.get(activeFilterCursor.key)) ?? + activeFilterCursor.index); + const activeItem = resolvedActiveIndex >= 0 ? itemSlots.get(resolvedActiveIndex) : undefined; + const activePageOffset = Math.floor(Math.max(0, resolvedActiveIndex) / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const activePageState = pageStates.get(activePageOffset); const selectedBoard = data.boards.find((board) => board.id === data.selectedBoardId); const boardName = selectedBoard ? getGalleryBoardLabel(selectedBoard, t) : t('widgets.gallery.selectedBoardFallback'); const uploadTarget = getGalleryUploadTargetLabel(data.boards, data.selectedBoardId, t); - const showsGrid = scope.pane === 'items' && listing.status !== 'error' && (items === null || items.length > 0); + const showsGrid = scope.pane === 'items' && data.listing.status !== 'error' && (total === null || total > 0); const isBoardListUnavailable = data.boardsState.status === 'error'; const getTileState = useCallback( @@ -244,19 +319,27 @@ export const GalleryPickerView = ({ return; } - // The previous scope's items are on screen only as a placeholder; nothing in them is pickable. - if (!items || isStale) { + if (isStale || data.listing.status === 'error') { + if (event.key === 'Enter' || isGalleryPickerNavKey(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } return; } if (isGalleryPickerNavKey(event.key) && isGalleryPickerNavKeyForField(event.key, event.currentTarget.value)) { - const next = items[getGalleryPickerNeighborIndex(resolvedActiveIndex, items.length, columnCount, event.key)]; + const nextIndex = getGalleryPickerNeighborIndex(resolvedActiveIndex, totalSlots, columnCount, event.key); event.preventDefault(); event.stopPropagation(); - if (next) { - setActiveKey(toGalleryItemKey(next)); + if (nextIndex >= 0) { + placeActiveCursor(filterIdentity, nextIndex, getSlotKey(nextIndex)); + + // A subscribed page keeps the range around it; narrowing to the cursor would drop the rest of the view. + if (!pageStates.has(Math.floor(nextIndex / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE)) { + setVisibleRange?.({ endIndexExclusive: nextIndex + 1, startIndex: nextIndex }); + } } } else if (event.key === 'Enter') { event.preventDefault(); @@ -264,10 +347,13 @@ export const GalleryPickerView = ({ if (activeItem) { pickItem(activeItem); + } else if (activePageState?.error) { + void activePageState.retry(); } } }, [ + activePageState, activeItem, boardGroups, boardsId, @@ -275,21 +361,36 @@ export const GalleryPickerView = ({ handleSelectBoard, isSearching, isStale, - items, + filterIdentity, + data.listing.status, + getSlotKey, + pageStates, pickItem, + placeActiveCursor, resolvedActiveIndex, + setVisibleRange, scope.pane, + totalSlots, ] ); const handleActivate = useCallback( (item: GalleryItem) => { - setActiveKey(toGalleryItemKey(item)); + const key = toGalleryItemKey(item); + const index = slotIndexByKey.get(key); + + if (index !== undefined) { + placeActiveCursor(filterIdentity, index, key); + } + pickItem(item); }, - [pickItem] + [filterIdentity, pickItem, placeActiveCursor, slotIndexByKey] + ); + const handleVisibleRangeChange = useCallback( + (range: { endIndexExclusive: number; startIndex: number }) => setVisibleRange?.(range), + [setVisibleRange] ); - const handleLoadMore = useCallback(() => loadMoreRef.current(), []); const handleClearSearch = useCallback(() => setSearchTerm(''), [setSearchTerm]); const openGallery = useCallback(() => { @@ -304,24 +405,28 @@ export const GalleryPickerView = ({ const pickerNotice = scope.pane !== 'items' ? null - : listing.status === 'more-error' + : data.listing.status === 'more-error' ? { message: 'widgets.gallery.listingLoadMoreFailed', retryLabel: 'widgets.gallery.retryLoadingMoreItems' } - : listing.status === 'stale-error' + : data.listing.status === 'stale-error' ? { message: 'widgets.gallery.listingRefreshFailed', retryLabel: 'widgets.gallery.retryLoadingItems' } : null; + const showPickerNotice = + pickerNotice !== null && + (data.sparseListing === undefined || [...pageStates.values()].some((pageState) => pageState.error !== null)); // Counts of a list that failed to load would claim it is empty. - const isCurrentPaneUnavailable = scope.pane === 'boards' ? isBoardListUnavailable : listing.status === 'error'; - const status = isCurrentPaneUnavailable + const shouldHideStatus = + isStale || (scope.pane === 'boards' ? isBoardListUnavailable : data.listing.status === 'error'); + const status = shouldHideStatus ? '' : getGalleryPickerStatus({ accept, activeItem, isSearching, isWindowTruncated: data.isWindowTruncated, - loadedCount: items?.length ?? 0, + loadedCount: itemSlots.size, pane: scope.pane, remaining: getGalleryPickerRemaining(accept, selection), - total: data.total, + total, visibleBoardCount, }) .map((part) => t(`widgets.gallery.picker.${part.kind}`, 'count' in part ? { count: part.count } : undefined)) @@ -345,11 +450,13 @@ export const GalleryPickerView = ({ : showsGrid ? { 'aria-activedescendant': - resolvedActiveKey && !isStale ? galleryPickerOptionId(listboxId, resolvedActiveKey) : undefined, + resolvedActiveIndex >= 0 && !isStale + ? galleryPickerOptionId(listboxId, resolvedActiveIndex) + : undefined, 'aria-controls': listboxId, } : undefined, - [boardsId, isStale, listboxId, resolvedActiveKey, scope.pane, showsGrid] + [boardsId, isStale, listboxId, resolvedActiveIndex, scope.pane, showsGrid] ); const searchLabel = scope.pane === 'boards' @@ -442,27 +549,31 @@ export const GalleryPickerView = ({ onExitTop={focusSearch} onSelect={handleSelectBoard} /> - ) : listing.status === 'error' ? ( + ) : data.listing.status === 'error' ? ( ) : showsGrid ? ( ) : ( @@ -480,7 +591,7 @@ export const GalleryPickerView = ({ ) : null} )} - {pickerNotice ? ( + {showPickerNotice ? ( diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/picker/useGalleryPickerScope.ts b/invokeai/frontend/webv2/src/features/gallery/ui/picker/useGalleryPickerScope.ts index a7bbb396791..c5c4d8bf745 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/picker/useGalleryPickerScope.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/picker/useGalleryPickerScope.ts @@ -52,6 +52,7 @@ export const useGalleryPickerScope = () => { searchTerm: scope.pane === 'items' ? deferredSearchTerm : '', selectedBoardId: scope.boardId, semanticQuery: null, + sparseViewport: true, settings, }); const gallerySelectedItem = useMemo(() => getSelectedGalleryItemFromValues(galleryValues), [galleryValues]); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts index 687afc5f33a..59ecedef666 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryActions.ts @@ -198,7 +198,10 @@ export const useGalleryActions = ({ }, selectBoard: gallery.selectBoard, selectItem: gallery.selectItem, - selectItemRange: (items, primaryItem) => gallery.setItemMultiSelection(items.map(toGalleryItemKey), primaryItem), + selectItemRange: (items, primaryItem, selectionPage) => + selectionPage === undefined + ? gallery.setItemMultiSelection(items.map(toGalleryItemKey), primaryItem) + : gallery.setItemMultiSelection(items.map(toGalleryItemKey), primaryItem, selectionPage), setCompareItem: gallery.setCompareItem, setSearchTerm: gallery.setSearchTerm, setStarredOnly: gallery.setStarredOnly, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx new file mode 100644 index 00000000000..434a55201e9 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.browser.test.tsx @@ -0,0 +1,924 @@ +import type { GalleryItem } from '@features/gallery/core/items'; +import type { GalleryImage } from '@features/gallery/core/types'; + +import { getGallerySettings } from '@features/gallery/core/settings'; +import { fetchGalleryItemsPage } from '@features/gallery/data/queries'; +import { invalidateGallery, patchGalleryItemCaches } from '@features/gallery/data/queryCache'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GalleryData } from './useGalleryData'; + +import { useGalleryData } from './useGalleryData'; + +const mocks = vi.hoisted(() => ({ listGalleryBoards: vi.fn(), listGalleryItems: vi.fn() })); + +vi.mock('@features/gallery/data/backend', async (importOriginal) => ({ + ...(await importOriginal>()), + listGalleryBoards: mocks.listGalleryBoards, + listGalleryItems: mocks.listGalleryItems, +})); + +const TOTAL = 12_000; +const settings = getGallerySettings({ paginationMode: 'infinite' }); +const paginatedSettings = getGallerySettings({ paginationMode: 'paginated' }); +let latestData: GalleryData | null = null; + +const readRequests = () => + mocks.listGalleryItems.mock.calls.map(([request]) => { + const { limit, offset } = request as { limit: number; offset: number }; + return { limit, offset }; + }); + +const createItem = (index: number): GalleryItem => ({ + boardId: 'none', + category: 'general', + createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + fullUrl: `/full/${index}`, + height: 64, + isIntermediate: false, + kind: 'image', + name: `image-${index}.png`, + starred: false, + thumbnailUrl: `/thumb/${index}`, + width: 64, +}); + +const recentImages: GalleryImage[] = [ + { + boardId: 'none', + height: 64, + imageCategory: 'general', + imageName: 'recent.png', + imageUrl: '/full/recent.png', + queuedAt: new Date(1_000).toISOString(), + sourceQueueItemId: 'recent-generation', + starred: false, + thumbnailUrl: '/thumb/recent.png', + width: 64, + }, +]; + +const Probe = ({ + page = 0, + paginated = false, + searchTerm = '', + recentImages = [], +}: { page?: number; paginated?: boolean; recentImages?: GalleryImage[]; searchTerm?: string } = {}) => { + const data = useGalleryData({ + galleryView: 'images', + page, + projectBoardId: null, + recentImages, + searchTerm, + selectedBoardId: null, + settings: paginated ? paginatedSettings : settings, + sparseViewport: true, + }); + useEffect(() => { + latestData = data; + }, [data]); + + return ( + <> + {data.total ?? 'unknown'} + {[...(data.sparseListing?.pageStates.keys() ?? [])].join(',')} + {data.sparseListing?.itemSlots.get(6_000)?.name ?? 'missing'} + {data.items?.map((item) => item.name).join(',') ?? 'unknown'} + + ); +}; + +const renderProbe = (props: Parameters[0] = {}) => + act(() => + root?.render( + + + + ) + ); + +const findPageQuery = (offset: number) => + queryClient + ?.getQueryCache() + .findAll({ queryKey: ['gallery', 'items', 'list'] }) + .find((query) => query.queryKey[5] === 'page' && query.queryKey[6] === offset); + +const mockListingTotal = (getTotal: () => number) => + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + const total = getTotal(); + + return Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }); + }); + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +let queryClient: QueryClient | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + accountLifecycle.activate('sparse-gallery-query-test'); + mocks.listGalleryBoards.mockImplementation(() => Promise.resolve([])); + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => + Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, TOTAL - offset)) }, (_, index) => + createItem(offset + index) + ), + total: TOTAL, + }) + ); + latestData = null; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + queryClient?.clear(); + host = null; + root = null; + queryClient = null; +}); + +describe('useGalleryData sparse page subscriptions', () => { + it('fetches only the selected paginated page and clamps later requests to the known final page', async () => { + const total = 62; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => + Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }) + ); + + await act(() => + root?.render( + + + + ) + ); + + await vi.waitFor(() => expect(latestData?.items).toHaveLength(2)); + expect(readRequests()).toEqual([ + { limit: 0, offset: 0 }, + { limit: 60, offset: 60 }, + ]); + expect([...latestData!.sparseListing!.itemSlots.keys()]).toEqual([0, 1]); + + await act(() => + root?.render( + + + + ) + ); + + expect(readRequests()).toEqual([ + { limit: 0, offset: 0 }, + { limit: 60, offset: 60 }, + ]); + expect([...latestData!.sparseListing!.pageStates.keys()]).toEqual([60]); + expect(latestData?.sparseListing?.itemSlots.get(0)?.name).toBe('image-60.png'); + expect(latestData?.sparseListing?.itemSlots.get(1)?.name).toBe('image-61.png'); + }); + + it('reconciles a stale infinite total from a pinned reveal page', async () => { + let total = 60; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => + Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }) + ); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(latestData?.total).toBe(60)); + + // Another client adds a 61st item; Find in Gallery verified it at index 60. + total = 61; + await act(() => latestData!.pinRevealIndex!(60)); + + await vi.waitFor(() => expect(latestData?.total).toBe(61)); + expect(latestData?.sparseListing?.itemSlots.get(60)?.name).toBe('image-60.png'); + }); + + it('keeps an empty cold paginated listing subscribed to page zero', async () => { + mocks.listGalleryItems.mockResolvedValue({ items: [], total: 0 }); + + await act(() => + root?.render( + + + + ) + ); + + await vi.waitFor(() => expect(latestData?.total).toBe(0)); + expect(readRequests()).toEqual([ + { limit: 0, offset: 0 }, + { limit: 60, offset: 0 }, + ]); + expect([...latestData!.sparseListing!.pageStates.keys()]).toEqual([0]); + expect(latestData?.items).toEqual([]); + }); + + it.each([ + ['infinite', false], + ['numbered', true], + ] as const)('refetches an empty %s listing after gallery invalidation', async (_mode, paginated) => { + let total = 0; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => + Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }) + ); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(latestData?.total).toBe(0)); + expect(latestData?.items).toEqual([]); + + total = 1; + await act(() => invalidateGallery(queryClient!)); + await vi.waitFor(() => { + expect(latestData?.total).toBe(1); + expect(latestData?.items?.map((item) => item.name)).toEqual(['image-0.png']); + }); + }); + + it('discovers total at page zero, then fetches only aligned pages around a distant range', async () => { + await act(() => + root?.render( + + + + ) + ); + + await vi.waitFor(() => expect(host?.querySelector('[data-testid="total"]')?.textContent).toBe(String(TOTAL))); + expect(readRequests()).toEqual([{ limit: 60, offset: 0 }]); + + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 6_120, startIndex: 6_000 })); + + await vi.waitFor(() => { + expect(readRequests()).toEqual([ + { limit: 60, offset: 0 }, + { limit: 60, offset: 6_000 }, + { limit: 60, offset: 6_060 }, + ]); + }); + + const activePageOffsets = queryClient + ?.getQueryCache() + .getAll() + .filter((query) => query.queryKey[5] === 'page' && query.getObserversCount() > 0) + .map((query) => query.queryKey[6] as number) + .sort((left, right) => left - right); + + expect(activePageOffsets).toEqual([6_000, 6_060]); + }); + + it('discovers a cold later page count before fetching only the clamped item page', async () => { + const total = 62; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => + Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }) + ); + + await act(() => + root?.render( + + + + ) + ); + + await vi.waitFor(() => expect(latestData?.items).toHaveLength(2)); + expect(readRequests()).toEqual([ + { limit: 0, offset: 0 }, + { limit: 60, offset: 60 }, + ]); + expect([...latestData!.sparseListing!.pageStates.keys()]).toEqual([60]); + }); + + it('does not restore a stale discovered count while a shrunken listing loads its clamped page', async () => { + let resolveShrunkPage!: (page: { items: GalleryItem[]; total: number }) => void; + let resolveFirstPage!: (page: { items: GalleryItem[]; total: number }) => void; + mocks.listGalleryItems.mockImplementation(({ limit, offset }: { limit: number; offset: number }) => { + if (limit === 0) { + return Promise.resolve({ items: [], total: 121 }); + } + + if (offset === 120) { + return new Promise((resolve) => { + resolveShrunkPage = resolve; + }); + } + + if (offset === 0) { + return new Promise((resolve) => { + resolveFirstPage = resolve; + }); + } + + throw new Error(`Unexpected item page at offset ${offset}`); + }); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(readRequests()).toContainEqual({ limit: 0, offset: 0 })); + await vi.waitFor(() => expect(readRequests()).toContainEqual({ limit: 60, offset: 120 })); + + let renderError: unknown; + try { + await act(async () => { + resolveShrunkPage({ items: [], total: 60 }); + await Promise.resolve(); + }); + } catch (error) { + renderError = error; + } + + expect(renderError).toBeUndefined(); + await vi.waitFor(() => expect(readRequests()).toContainEqual({ limit: 60, offset: 0 })); + expect(latestData?.total).toBe(60); + expect(readRequests().filter(({ limit, offset }) => limit === 60 && offset === 120)).toHaveLength(1); + + await act(async () => { + resolveFirstPage({ + items: Array.from({ length: 60 }, (_, index) => createItem(index)), + total: 60, + }); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(latestData?.items).toHaveLength(60)); + expect(latestData?.total).toBe(60); + expect(readRequests().filter(({ limit, offset }) => limit === 60 && offset === 120)).toHaveLength(1); + }); + + it('settles on the clamped page when a removal empties the last paginated page', async () => { + let total = 61; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => + Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }) + ); + + // Page zero is cached from an earlier visit. + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(latestData?.items).toHaveLength(60)); + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(latestData?.items?.map((item) => item.name)).toEqual(['image-60.png'])); + const readsBeforeRemoval = readRequests().length; + + let renderError: unknown; + try { + await act(async () => { + // The server has applied the move, so only a needless read, not its result, would tell. + total = 60; + patchGalleryItemCaches(queryClient!, { + boardId: 'elsewhere', + kind: 'move', + result: { failed: [], succeeded: [{ kind: 'image', name: 'image-60.png' }] }, + }); + await Promise.resolve(); + }); + } catch (error) { + renderError = error; + } + + expect(renderError).toBeUndefined(); + await vi.waitFor(() => expect(latestData?.total).toBe(60)); + expect(latestData?.items).toHaveLength(60); + // Both pages lost the item together, so their totals agree and nothing is read again. + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 50); + }) + ); + expect(readRequests()).toHaveLength(readsBeforeRemoval); + expect(latestData?.total).toBe(60); + }); + + it('reconciles a stale page total by refetching instead of flipping between clamped pages', async () => { + // The server has not applied the move yet, so every read still reports 61 items. + mockListingTotal(() => 61); + await renderProbe({ paginated: true }); + await vi.waitFor(() => expect(latestData?.items).toHaveLength(60)); + await renderProbe({ page: 1, paginated: true }); + await vi.waitFor(() => expect(latestData?.items?.map((item) => item.name)).toEqual(['image-60.png'])); + await act(async () => { + patchGalleryItemCaches(queryClient!, { + boardId: 'elsewhere', + kind: 'move', + result: { failed: [], succeeded: [{ kind: 'image', name: 'image-60.png' }] }, + }); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(latestData?.total).toBe(60)); + + let renderError: unknown; + try { + await act(async () => { + // A refetch of the shown page lands before the mutation and reports the old total. + await queryClient!.invalidateQueries({ exact: true, queryKey: findPageQuery(0)!.queryKey }); + }); + } catch (error) { + renderError = error; + } + + expect(renderError).toBeUndefined(); + await vi.waitFor(() => { + expect(latestData?.total).toBe(61); + expect(latestData?.items?.map((item) => item.name)).toEqual(['image-60.png']); + }); + expect(readRequests().filter(({ limit, offset }) => limit === 60 && offset === 0)).toHaveLength(3); + expect(readRequests().filter(({ limit, offset }) => limit === 60 && offset === 60)).toHaveLength(2); + }); + + it('stops reconciling when the requested page and the shown page keep reporting different totals', async () => { + // A server whose reads never agree: the first page counts 61 items, which puts the request on page 1, while + // page 1 counts 60, which clamps it back to page 0. + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + const total = offset === 0 ? 61 : 60; + + return Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }); + }); + + let renderError: unknown; + try { + await renderProbe({ page: 1, paginated: true }); + await vi.waitFor(() => expect(latestData?.items?.length).toBeGreaterThan(0)); + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 200); + }) + ); + } catch (error) { + renderError = error; + } + + expect(renderError).toBeUndefined(); + const settledReads = readRequests().length; + const pageReads = (offset: number) => + readRequests().filter((request) => request.limit === 60 && request.offset === offset).length; + + // One reconciliation reads each page again, then the disagreement is left alone. + expect(pageReads(0)).toBeLessThanOrEqual(2); + expect(pageReads(60)).toBeLessThanOrEqual(2); + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 200); + }) + ); + expect(readRequests()).toHaveLength(settledReads); + }); + + it('opens a verified paginated page past a stale retained total', async () => { + let total = 60; + mockListingTotal(() => total); + await renderProbe({ paginated: true }); + await vi.waitFor(() => expect(latestData?.total).toBe(60)); + + // Another client adds a 61st item; Find in Gallery verifies it on page 1 before selecting that page. + total = 61; + await act(() => fetchGalleryItemsPage(queryClient!, latestData!.filter, 60, { staleTime: 0 })); + await renderProbe({ page: 1, paginated: true }); + + await vi.waitFor(() => expect(latestData?.items?.map((item) => item.name)).toEqual(['image-60.png'])); + expect(latestData?.total).toBe(61); + }); + + it('does not reconcile page totals after an optimistic removal across subscribed pages', async () => { + await renderProbe(); + await vi.waitFor(() => expect(latestData?.total).toBe(TOTAL)); + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 120, startIndex: 0 })); + await vi.waitFor(() => { + expect(latestData?.sparseListing?.itemSlots.get(60)?.name).toBe('image-60.png'); + expect(latestData?.isLoadingItems).toBe(false); + }); + const requestCount = readRequests().length; + + await act(async () => { + patchGalleryItemCaches(queryClient!, { + kind: 'delete', + result: { failed: [], succeeded: [{ kind: 'image', name: 'image-5.png' }] }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + }); + + expect(latestData?.total).toBe(TOTAL - 1); + expect(readRequests()).toHaveLength(requestCount); + }); + + it('reads a failed page again when it is subscribed after leaving the view, but not while it stays visible', async () => { + let distantPageReads = 0; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + if (offset === 6_000 && ++distantPageReads === 1) { + return Promise.reject(new Error('temporary page failure')); + } + + return Promise.resolve({ + items: Array.from({ length: limit }, (_, index) => createItem(offset + index)), + total: TOTAL, + }); + }); + await renderProbe(); + await vi.waitFor(() => expect(latestData?.total).toBe(TOTAL)); + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 6_060, startIndex: 6_000 })); + await vi.waitFor(() => + expect(latestData?.sparseListing?.pageStates.get(6_000)?.error?.message).toBe('temporary page failure') + ); + + await act(() => invalidateGallery(queryClient!)); + expect(distantPageReads).toBe(1); + + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 60, startIndex: 0 })); + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 6_060, startIndex: 6_000 })); + + await vi.waitFor(() => expect(latestData?.sparseListing?.itemSlots.get(6_000)?.name).toBe('image-6000.png')); + expect(distantPageReads).toBe(2); + }); + + it('surfaces count errors and retries count discovery before loading a clamped page', async () => { + let attempts = 0; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + attempts += 1; + if (attempts === 1) { + return Promise.reject(new Error('count unavailable')); + } + + return Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, 62 - offset)) }, (_, index) => + createItem(offset + index) + ), + total: 62, + }); + }); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(latestData?.queryError?.message).toBe('count unavailable')); + expect([...latestData!.sparseListing!.pageStates.keys()]).toEqual([0]); + await act(async () => { + await latestData?.sparseListing?.pageStates.get(0)?.retry(); + }); + await vi.waitFor(() => expect(latestData?.items).toHaveLength(2)); + expect(readRequests()).toEqual([ + { limit: 0, offset: 0 }, + { limit: 0, offset: 0 }, + { limit: 60, offset: 60 }, + ]); + }); + + it.each(['filter', 'account'] as const)('does not apply a retry result after a %s transition', async (transition) => { + let activeScope = 'old'; + let oldPageCallCount = 0; + let resolveOldRetry: (() => void) | undefined; + const requestsByScope: { limit: number; offset: number; scope: string }[] = []; + mocks.listGalleryItems.mockImplementation( + ({ limit, offset, searchTerm }: { limit: number; offset: number; searchTerm: string }) => { + const scope = searchTerm === 'new listing' || activeScope === 'new' ? 'new' : 'old'; + requestsByScope.push({ limit, offset, scope }); + + if (scope === 'new') { + return Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, 62 - offset)) }, (_, index) => ({ + ...createItem(offset + index), + name: `new-${offset + index}.png`, + })), + total: 62, + }); + } + + if (limit === 0) { + return Promise.resolve({ items: [], total: 62 }); + } + + if (offset === 60 && oldPageCallCount === 0) { + oldPageCallCount += 1; + return Promise.reject(new Error('temporary page failure')); + } + + if (offset === 60) { + oldPageCallCount += 1; + } + + return new Promise((resolve) => { + resolveOldRetry = () => + resolve({ + items: Array.from({ length: 2 }, (_, index) => ({ + ...createItem(offset + index), + name: `stale-${offset + index}.png`, + })), + total: 62, + }); + }); + } + ); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(latestData?.queryError?.message).toBe('temporary page failure')); + + act(() => { + void latestData?.sparseListing?.pageStates.get(60)?.retry(); + }); + await vi.waitFor(() => expect(oldPageCallCount).toBe(2)); + + if (transition === 'account') { + await act(() => { + activeScope = 'new'; + accountLifecycle.activate('gallery-query-retry-new-account'); + root?.render( + + + + ); + }); + } else { + await act(() => + root?.render( + + + + ) + ); + } + await vi.waitFor(() => expect(latestData?.items?.map((item) => item.name)).toEqual(['new-61.png', 'new-60.png'])); + + await act(() => resolveOldRetry?.()); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(latestData?.items?.map((item) => item.name)).toEqual(['new-61.png', 'new-60.png']); + expect(latestData?.total).toBe(62); + const newScopeRequests = requestsByScope.filter(({ scope }) => scope === 'new'); + expect(newScopeRequests.map(({ limit, offset }) => ({ limit, offset }))).toEqual([ + { limit: 0, offset: 0 }, + { limit: 60, offset: 60 }, + ]); + }); + + it('keeps an authoritative recent out of the overlay after its page is evicted and reloaded', async () => { + const authoritativeItem = { ...createItem(0), name: 'recent.png' }; + const pageCalls = new Map(); + let resolvePageZero: (() => void) | undefined; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + pageCalls.set(offset, (pageCalls.get(offset) ?? 0) + 1); + const items = + offset === 0 + ? [authoritativeItem, ...Array.from({ length: limit - 1 }, (_, index) => createItem(index + 1))] + : Array.from({ length: limit }, (_, index) => createItem(offset + index)); + + if (offset === 0 && pageCalls.get(offset) === 1) { + return new Promise((resolve) => { + resolvePageZero = () => resolve({ items, total: TOTAL }); + }); + } + + return Promise.resolve({ items, total: TOTAL }); + }); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => { + expect(latestData?.items?.filter((item) => item.name === 'recent.png')).toHaveLength(1); + expect(latestData?.sparseListing?.recentItems.map((item) => item.name)).toEqual(['recent.png']); + }); + + await act(() => resolvePageZero?.()); + await vi.waitFor(() => expect(latestData?.sparseListing?.recentItems).toEqual([])); + expect(latestData?.items?.filter((item) => item.name === 'recent.png')).toHaveLength(1); + expect(latestData?.sparseListing?.itemSlots.get(0)?.name).toBe('recent.png'); + expect(latestData?.total).toBe(TOTAL); + + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 6_060, startIndex: 6_000 })); + await vi.waitFor(() => expect(latestData?.sparseListing?.pageStates.has(6_000)).toBe(true)); + const pageZero = queryClient + ?.getQueryCache() + .findAll({ queryKey: ['gallery', 'items', 'list'] }) + .find((query) => query.queryKey[5] === 'page' && query.queryKey[6] === 0); + expect(pageZero?.getObserversCount()).toBe(0); + if (pageZero) { + await act(() => queryClient?.removeQueries({ exact: true, queryKey: pageZero.queryKey })); + } + + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 60, startIndex: 0 })); + await vi.waitFor(() => { + expect(pageCalls.get(0)).toBe(2); + expect(latestData?.items?.filter((item) => item.name === 'recent.png')).toHaveLength(1); + }); + expect(latestData?.items?.filter((item) => item.name === 'recent.png')).toHaveLength(1); + expect(latestData?.sparseListing?.itemSlots.get(0)?.name).toBe('recent.png'); + expect(latestData?.sparseListing?.recentItems).toEqual([]); + expect(latestData?.total).toBe(TOTAL); + }); + + it('uses an indexed distant range while the initial page is still pending', async () => { + let releaseCountDiscovery: (() => void) | undefined; + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + if (offset === 0) { + return new Promise((resolve) => { + releaseCountDiscovery = () => + resolve({ + items: Array.from({ length: limit }, (_, index) => createItem(index)), + total: TOTAL, + }); + }); + } + + return Promise.resolve({ + items: Array.from({ length: Math.max(0, Math.min(limit, TOTAL - offset)) }, (_, index) => + createItem(offset + index) + ), + total: TOTAL, + }); + }); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(readRequests()).toEqual([{ limit: 60, offset: 0 }])); + + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 6_060, startIndex: 6_000 })); + await vi.waitFor(() => { + expect(readRequests()).toEqual([ + { limit: 60, offset: 0 }, + { limit: 60, offset: 6_000 }, + ]); + }); + const activePageOffsets = queryClient + ?.getQueryCache() + .getAll() + .filter((query) => query.queryKey[5] === 'page' && query.getObserversCount() > 0) + .map((query) => query.queryKey[6] as number); + expect(activePageOffsets).toEqual([6_000]); + + releaseCountDiscovery?.(); + }); + + it('keeps the logical sparse range stable while reconciling page totals', async () => { + const retryPageResolvers = new Map void>(); + const pageCallCounts = new Map(); + const createPage = (offset: number, limit: number, total: number) => ({ + items: Array.from({ length: Math.max(0, Math.min(limit, total - offset)) }, (_, index) => + createItem(offset + index) + ), + total, + }); + mocks.listGalleryItems.mockImplementation(({ offset, limit }: { offset: number; limit: number }) => { + const callCount = (pageCallCounts.get(offset) ?? 0) + 1; + pageCallCounts.set(offset, callCount); + + if (offset === 0) { + return Promise.resolve(createPage(offset, limit, TOTAL)); + } + + if (callCount === 1 || callCount === 3) { + return Promise.resolve(createPage(offset, limit, offset === 6_000 ? 11_000 : 10_000)); + } + + if (callCount === 2 || callCount === 4) { + return new Promise((resolve) => { + retryPageResolvers.set(offset, () => { + resolve(createPage(offset, limit, 11_000)); + }); + }); + } + + return Promise.resolve(createPage(offset, limit, 11_000)); + }); + + await act(() => + root?.render( + + + + ) + ); + await vi.waitFor(() => expect(host?.querySelector('[data-testid="total"]')?.textContent).toBe(String(TOTAL))); + + await act(() => latestData?.setVisibleRange?.({ endIndexExclusive: 6_120, startIndex: 6_000 })); + await vi.waitFor(() => expect(retryPageResolvers.size).toBe(2)); + + expect(host?.querySelector('[data-testid="total"]')?.textContent).toBe(String(TOTAL)); + expect(host?.querySelector('[data-testid="page-offsets"]')?.textContent).toBe('6000,6060'); + expect(host?.querySelector('[data-testid="anchor"]')?.textContent).toBe('image-6000.png'); + + await act(() => { + retryPageResolvers.forEach((resolve) => resolve()); + }); + retryPageResolvers.clear(); + await vi.waitFor(() => expect(host?.querySelector('[data-testid="total"]')?.textContent).toBe('11000')); + + expect(host?.querySelector('[data-testid="page-offsets"]')?.textContent).toBe('6000,6060'); + expect(host?.querySelector('[data-testid="anchor"]')?.textContent).toBe('image-6000.png'); + + await act(async () => { + const activePages = latestData?.sparseListing?.pageStates; + await Promise.all([activePages?.get(6_000)?.retry(), activePages?.get(6_060)?.retry()]); + }); + await vi.waitFor(() => expect(retryPageResolvers.size).toBe(2)); + + expect(host?.querySelector('[data-testid="total"]')?.textContent).toBe('11000'); + expect(host?.querySelector('[data-testid="page-offsets"]')?.textContent).toBe('6000,6060'); + expect(host?.querySelector('[data-testid="anchor"]')?.textContent).toBe('image-6000.png'); + + await act(() => { + retryPageResolvers.forEach((resolve) => resolve()); + }); + await vi.waitFor(() => expect(latestData?.isLoadingItems).toBe(false)); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(host?.querySelector('[data-testid="total"]')?.textContent).toBe('11000'); + expect(host?.querySelector('[data-testid="page-offsets"]')?.textContent).toBe('6000,6060'); + expect(host?.querySelector('[data-testid="anchor"]')?.textContent).toBe('image-6000.png'); + expect(pageCallCounts.get(6_000)).toBe(4); + expect(pageCallCounts.get(6_060)).toBe(4); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts index 9f287f41fe6..059cf7020b7 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.test.ts @@ -4,7 +4,12 @@ import type { GalleryImage, GeneratedImageContract } from '@features/gallery/cor import { getBoundedRecentImages } from '@features/gallery/core/recentImages'; import { describe, expect, it } from 'vitest'; -import { isGalleryWindowTruncated, mergeGalleryItemWindow } from './useGalleryData'; +import { + getGalleryRecentItems, + isGalleryWindowTruncated, + mapGalleryItemPageSlots, + mergeGalleryItemWindow, +} from './useGalleryData'; const createImage = (index: number, overrides: Partial = {}): GalleryImage => ({ boardId: 'none', @@ -43,6 +48,63 @@ const createBackendItem = (name: string, createdAt: string): GalleryItem => ({ width: 512, }); +describe('mapGalleryItemPageSlots', () => { + it('retains omitted hydration slots and offsets distant pages absolutely', () => { + const first = createBackendItem('first.png', '2026-01-01T00:00:00.000Z'); + const third = createBackendItem('third.png', '2026-01-01T00:00:02.000Z'); + const slots = mapGalleryItemPageSlots({ + pageOffsets: [600], + pages: [{ itemIndices: [600, 602], items: [first, third], offset: 600 }], + }); + + expect([...slots.entries()]).toEqual([ + [600, first], + [602, third], + ]); + expect(slots.has(601)).toBe(false); + }); + + it('falls back to requested offset for legacy page fixtures and makes paginated positions page-local', () => { + const first = createBackendItem('first.png', '2026-01-01T00:00:00.000Z'); + const second = createBackendItem('second.png', '2026-01-01T00:00:01.000Z'); + const slots = mapGalleryItemPageSlots({ + pageLocalOffset: 120, + pageOffsets: [120], + pages: [{ items: [first, second] }], + }); + + expect([...slots.entries()]).toEqual([ + [0, first], + [1, second], + ]); + }); +}); + +describe('getGalleryRecentItems', () => { + it('keeps local outputs separate and follows the selected order direction', () => { + const first = asGenerated(createImage(1, { imageName: 'recent-1.png' })); + const second = asGenerated(createImage(2, { imageName: 'recent-2.png' })); + + expect( + getGalleryRecentItems({ backendItems: [], filter, recentImages: [first, second] }).map((item) => item.name) + ).toEqual(['recent-2.png', 'recent-1.png']); + expect( + getGalleryRecentItems({ + backendItems: [], + filter: { ...filter, orderDir: 'ASC' }, + recentImages: [first, second], + }).map((item) => item.name) + ).toEqual(['recent-1.png', 'recent-2.png']); + expect( + getGalleryRecentItems({ + backendItems: [createBackendItem('recent-1.png', first.queuedAt)], + filter, + recentImages: [first], + }) + ).toEqual([]); + }); +}); + describe('mergeGalleryItemWindow', () => { it('deduplicates by qualified key and mirrors server time/kind/name ordering', () => { const image = { diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts index e562d139d2f..a033e5678b2 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryData.ts @@ -12,13 +12,23 @@ import { GALLERY_PAGE_SIZE, galleryBoardsOptions, galleryItemsInfiniteOptions, + galleryItemsPageOptions, + galleryItemsTotalOptions, getGalleryListingBoardsQuery, type GalleryItemsFilter, } from '@features/gallery/data/queries'; import { parseDateTokens } from '@platform/search/dateTokens'; -import { hashKey, keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo, useState } from 'react'; +import { + hashKey, + keepPreviousData, + useInfiniteQuery, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { planGalleryPageOffsets } from './galleryGridLayout'; import { getGalleryListing, getGalleryReadStatus, @@ -36,12 +46,15 @@ export interface GalleryData { boardsState: GalleryReadState; filter: GalleryItemsFilter; hasMore: boolean; + isLoadingItems: boolean; /** The resolved board the items were fetched for. */ selectedBoardId: string; /** Distinguish reaching the window cap from reaching the board end; only truncation needs an explanation. */ isWindowTruncated: boolean; /** The current scope's items; null until it has something to show, and whenever it failed without data. */ items: GalleryItem[] | null; + /** The current query's failure, or null while it is healthy. */ + queryError: Error | null; listing: GalleryListingState; loadMore: () => void; /** @@ -50,8 +63,69 @@ export interface GalleryData { */ previousScopeItems: GalleryItem[] | null; total: number | null; + /** Absolute backend positions for the currently subscribed pages in the main Gallery and picker. */ + sparseListing?: GallerySparseListing; + setVisibleRange?: (range: { endIndexExclusive: number; startIndex: number }) => void; + /** + * Keep a locator-verified index's page subscribed even past a stale total, so that page's fresher total reconciles + * the listing. Lasts until the listing changes or another reveal replaces it. + */ + pinRevealIndex?: (absoluteIndex: number) => void; +} + +export interface GallerySparsePageState { + error: Error | null; + isLoading: boolean; + retry: () => Promise; +} + +export interface GallerySparseListing { + itemSlots: ReadonlyMap; + pageStates: ReadonlyMap; + /** Project-local outputs stay in separate virtual rows and never change backend item indexes. */ + recentItems: GalleryItem[]; + total: number | null; } +/** Rebuild absolute slots without filling hydration gaps or assuming returned-item position equals requested index. */ +export const mapGalleryItemPageSlots = ({ + pageLocalOffset, + pageOffsets, + pages, +}: { + pageLocalOffset?: number; + pageOffsets: readonly number[]; + pages: readonly ( + | { + itemIndices?: readonly number[]; + items: readonly GalleryItem[]; + offset?: number; + } + | undefined + )[]; +}): Map => { + const slots = new Map(); + + pages.forEach((page, pageIndex) => { + if (!page) { + return; + } + + const pageOffset = page.offset ?? pageOffsets[pageIndex] ?? 0; + + page.items.forEach((item, itemIndex) => { + const absoluteIndex = page.itemIndices?.[itemIndex] ?? pageOffset + itemIndex; + const slotIndex = pageLocalOffset === undefined ? absoluteIndex : absoluteIndex - pageLocalOffset; + + if (Number.isInteger(slotIndex) && slotIndex >= 0) { + slots.set(slotIndex, item); + } + }); + }); + + return slots; +}; + const EMPTY_BOARDS: GalleryBoard[] = []; const useGalleryBoards = ({ settings }: { settings: GallerySettings }) => { @@ -98,19 +172,17 @@ const isRecentItemVisible = (item: GalleryItem, filter: GalleryItemsFilter): boo export const mergeGalleryItemWindow = ({ backendItems, filter, + knownBackendItemKeys, maxRows, recentImages, }: { backendItems: readonly GalleryItem[]; filter: GalleryItemsFilter; + knownBackendItemKeys?: ReadonlySet>; maxRows: number; recentImages: readonly GeneratedImageContract[]; }): GalleryItem[] => { - const backendItemKeys = new Set(backendItems.map(toGalleryItemKey)); - const missingRecentItems = recentImages - .slice(0, GALLERY_RECENT_IMAGE_LIMIT) - .map(legacyGeneratedImageToGalleryItem) - .filter((item) => !backendItemKeys.has(toGalleryItemKey(item)) && isRecentItemVisible(item, filter)); + const missingRecentItems = getGalleryRecentItems({ backendItems, filter, knownBackendItemKeys, recentImages }); const seenItemKeys = new Set(); const mergedItems = [...missingRecentItems, ...backendItems].filter((item) => { @@ -134,6 +206,36 @@ export const mergeGalleryItemWindow = ({ return mergedItems.slice(0, maxRows); }; +/** Returns the bounded local recent overlay without assigning it a backend position. */ +export const getGalleryRecentItems = ({ + backendItems, + knownBackendItemKeys, + filter, + recentImages, +}: { + backendItems: readonly GalleryItem[]; + knownBackendItemKeys?: ReadonlySet>; + filter: GalleryItemsFilter; + recentImages: readonly GeneratedImageContract[]; +}): GalleryItem[] => { + const backendItemKeys = new Set(backendItems.map(toGalleryItemKey)); + const recentItems = recentImages + .slice(0, GALLERY_RECENT_IMAGE_LIMIT) + .map(legacyGeneratedImageToGalleryItem) + .filter( + (item) => + !backendItemKeys.has(toGalleryItemKey(item)) && + !knownBackendItemKeys?.has(toGalleryItemKey(item)) && + isRecentItemVisible(item, filter) + ); + + if (!filter.semanticQuery) { + recentItems.sort((a, b) => compareGalleryItems(a, b, { orderDir: filter.orderDir })); + } + + return recentItems; +}; + /** A false hasNextPage can mean either completion or truncation. Paginated mode remains fully reachable. */ export const isGalleryWindowTruncated = ({ hasNextPage, @@ -160,6 +262,7 @@ export const useGalleryData = ({ semanticQuery = null, settings, starred, + sparseViewport = false, }: { galleryView: GalleryView; /** Hold the previous scope's items as `previousScopeItems` while a new scope loads. */ @@ -178,9 +281,12 @@ export const useGalleryData = ({ * see everything. */ starred?: boolean; + /** Main Gallery and picker opt into page-sized viewport subscriptions; other consumers may keep the bounded window. */ + sparseViewport?: boolean; }): GalleryData => { const { boards, boardsState } = useGalleryBoards({ settings }); const boardId = resolveGallerySelectedBoardId({ projectBoardId, selectedBoardId }, boards); + const queryClient = useQueryClient(); const isPaginated = settings.paginationMode === 'paginated'; const dateParse = useMemo(() => parseDateTokens(searchTerm), [searchTerm]); const filter = useMemo( @@ -205,6 +311,249 @@ export const useGalleryData = ({ starred, ] ); + // Query keys include the captured account epoch, so range and total state cannot survive an account switch. + const firstPageOptions = galleryItemsPageOptions(filter, 0); + const totalOptions = galleryItemsTotalOptions(filter); + const { accountId, epoch } = firstPageOptions.queryKey[3]; + const filterIdentity = JSON.stringify([accountId, epoch, filter]); + const filterIdentityRef = useRef(filterIdentity); + useLayoutEffect(() => { + filterIdentityRef.current = filterIdentity; + }, [filterIdentity]); + const [requestedRange, setRequestedRange] = useState<{ + endIndexExclusive: number; + filterIdentity: string; + startIndex: number; + } | null>(null); + const hasRequestedRange = requestedRange?.filterIdentity === filterIdentity; + const visibleStartIndex = hasRequestedRange ? requestedRange.startIndex : 0; + const visibleEndIndexExclusive = hasRequestedRange ? requestedRange.endIndexExclusive : GALLERY_PAGE_SIZE; + const setVisibleRange = useCallback( + ({ endIndexExclusive, startIndex }: { endIndexExclusive: number; startIndex: number }) => { + const activeFilterIdentity = filterIdentityRef.current; + const nextStart = Math.max(0, Math.floor(startIndex)); + const nextEnd = Math.max(nextStart, Math.ceil(endIndexExclusive)); + + setRequestedRange((current) => + current?.filterIdentity === activeFilterIdentity && + current.startIndex === nextStart && + current.endIndexExclusive === nextEnd + ? current + : { endIndexExclusive: nextEnd, filterIdentity: activeFilterIdentity, startIndex: nextStart } + ); + }, + [] + ); + const [revealPin, setRevealPin] = useState<{ filterIdentity: string; offset: number } | null>(null); + const revealPinOffset = revealPin?.filterIdentity === filterIdentity ? revealPin.offset : null; + const pinRevealIndex = useCallback((absoluteIndex: number) => { + const offset = Math.floor(Math.max(0, absoluteIndex) / GALLERY_PAGE_SIZE) * GALLERY_PAGE_SIZE; + const activeFilterIdentity = filterIdentityRef.current; + + setRevealPin((current) => + current?.filterIdentity === activeFilterIdentity && current.offset === offset + ? current + : { filterIdentity: activeFilterIdentity, offset } + ); + }, []); + const [knownTotalSnapshot, setKnownTotalSnapshot] = useState<{ filterIdentity: string; total: number } | null>(null); + const retainedTotal = knownTotalSnapshot?.filterIdentity === filterIdentity ? knownTotalSnapshot.total : null; + const cachedFirstPage = queryClient.getQueryData<{ items: GalleryItem[]; total: number }>(firstPageOptions.queryKey); + const { + data: queriedTotal, + error: totalError, + isFetching: isFetchingTotal, + refetch: refetchTotal, + } = useQuery({ + ...totalOptions, + enabled: sparseViewport && isPaginated && page > 0 && retainedTotal === null && cachedFirstPage === undefined, + }); + const hasUnresolvedTotalError = totalError !== null && retainedTotal === null && cachedFirstPage === undefined; + const isInitialTotalDiscovery = retainedTotal === null && cachedFirstPage === undefined; + const stableTotal = + retainedTotal ?? cachedFirstPage?.total ?? (isInitialTotalDiscovery ? queriedTotal : undefined) ?? null; + const knownTotal = stableTotal; + const requestedPageOffset = Math.max(0, page) * GALLERY_PAGE_SIZE; + const getCachedPageTotal = (offset: number) => + queryClient.getQueryData<{ total: number }>(galleryItemsPageOptions(filter, offset).queryKey)?.total; + // A cached requested page's own total says whether that page exists, and is fresher than the retained total when + // Find in Gallery has just verified it. Clamping by it also keeps the selection independent of observed totals. + const requestedPageTotal = sparseViewport && isPaginated ? getCachedPageTotal(requestedPageOffset) : undefined; + const paginatedTotal = requestedPageTotal ?? knownTotal; + const clampPaginatedOffset = (total: number | null) => + total === null || !Number.isFinite(total) + ? requestedPageOffset + : Math.min( + requestedPageOffset, + Math.max(0, Math.ceil(Math.max(0, total) / GALLERY_PAGE_SIZE) - 1) * GALLERY_PAGE_SIZE + ); + const selectedPageOffset = clampPaginatedOffset(paginatedTotal); + const pageOffsets = useMemo(() => { + if (!sparseViewport) { + return []; + } + + if (isPaginated) { + if (paginatedTotal === 0) { + // Keep page zero observed so gallery invalidation can discover items added after an empty result. + return [0]; + } + + if (knownTotal === null || isFetchingTotal || hasUnresolvedTotalError) { + return page === 0 ? [0] : []; + } + + return [selectedPageOffset]; + } + + // Infinite listings learn their total from page zero. Once known, subscriptions follow only the virtual range + // and its virtualizer overscan, even when it is far from the start of the listing. + const plannedOffsets = + knownTotal === 0 || (knownTotal === null && !hasRequestedRange) + ? [0] + : planGalleryPageOffsets({ + endIndexExclusive: visibleEndIndexExclusive, + startIndex: visibleStartIndex, + total: knownTotal, + }); + + // A revealed item may sit past a total counted before another client added it. + return revealPinOffset === null || plannedOffsets.includes(revealPinOffset) + ? plannedOffsets + : [...plannedOffsets, revealPinOffset].sort((left, right) => left - right); + }, [ + hasRequestedRange, + isPaginated, + knownTotal, + isFetchingTotal, + hasUnresolvedTotalError, + page, + paginatedTotal, + revealPinOffset, + selectedPageOffset, + sparseViewport, + visibleEndIndexExclusive, + visibleStartIndex, + ]); + const [retryingSparseErrors, setRetryingSparseErrors] = useState>(() => new Map()); + const getSparsePageErrorKey = useCallback((offset: number) => `${filterIdentity}:${offset}`, [filterIdentity]); + const retrySparsePage = useCallback( + async (offset: number, refetchPage: () => Promise, error: Error | null) => { + const errorKey = `${filterIdentity}:${offset}`; + + if (error) { + setRetryingSparseErrors((current) => new Map(current).set(errorKey, error)); + } + + try { + await refetchPage(); + } finally { + setRetryingSparseErrors((current) => { + if (!current.has(errorKey)) { + return current; + } + + const next = new Map(current); + next.delete(errorKey); + return next; + }); + } + }, + [filterIdentity] + ); + // A paginated view shows one page but also observes the pages its clamp points at: the requested page, and the page + // the shown page's own total clamps to. Their totals must agree before the retained total moves, so disagreeing + // totals reconcile through one refetch instead of flipping the selected page on every render. + const totalCheckOffsets = new Set(); + + if (sparseViewport && isPaginated && pageOffsets[0] === selectedPageOffset) { + const selectedPageTotal = getCachedPageTotal(selectedPageOffset); + + if (requestedPageTotal !== undefined) { + totalCheckOffsets.add(requestedPageOffset); + } + if (selectedPageTotal !== undefined) { + totalCheckOffsets.add(clampPaginatedOffset(selectedPageTotal)); + } + totalCheckOffsets.delete(selectedPageOffset); + } + + const toPageOptions = (offset: number) => { + const options = galleryItemsPageOptions(filter, offset); + + // Failed pages wait for their own Retry; broad invalidation must not silently retry visible failures. + return { ...options, enabled: queryClient.getQueryState(options.queryKey)?.status !== 'error' }; + }; + const pageOptions = sparseViewport ? pageOffsets.map(toPageOptions) : []; + const observedPageOptions = [...pageOptions, ...[...totalCheckOffsets].map(toPageOptions)]; + const observedPageResults = useQueries({ queries: observedPageOptions }); + const pageResults = observedPageResults.slice(0, pageOptions.length); + const loadedPageTotals = observedPageResults.flatMap((result) => (result.data ? [result.data.total] : [])); + const hasConflictingPageTotals = new Set(loadedPageTotals).size > 1; + // A paginated page still loading its first data has no total to disagree with. Agreement alone cannot flip the + // selection back: a page loaded later is checked against the page its total clamps to before the total moves. + const pageResultsSettled = observedPageResults.every( + (result) => !result.isFetching || (isPaginated && result.data === undefined) + ); + const observedTotal = + sparseViewport && pageResultsSettled && !hasConflictingPageTotals ? loadedPageTotals[0] : undefined; + const [pageTotalReconciliation, setPageTotalReconciliation] = useState({ + conflictObserved: false, + filterIdentity, + generation: 0, + }); + const currentPageTotalReconciliation = + pageTotalReconciliation.filterIdentity === filterIdentity + ? pageTotalReconciliation + : { conflictObserved: false, filterIdentity, generation: 0 }; + + // A settled agreement ends the current conflict generation. The next conflict can then reconcile even when its + // filter, total, and active page offsets match an earlier generation. + if (sparseViewport && pageResultsSettled && loadedPageTotals.length > 0) { + if (hasConflictingPageTotals && !currentPageTotalReconciliation.conflictObserved) { + setPageTotalReconciliation({ ...currentPageTotalReconciliation, conflictObserved: true }); + } else if (!hasConflictingPageTotals && currentPageTotalReconciliation.conflictObserved) { + setPageTotalReconciliation({ + ...currentPageTotalReconciliation, + conflictObserved: false, + generation: currentPageTotalReconciliation.generation + 1, + }); + } + } + + useQuery({ + enabled: sparseViewport && pageResultsSettled && hasConflictingPageTotals, + gcTime: 0, + queryFn: async ({ client }) => { + await Promise.all(observedPageOptions.map(({ queryKey }) => client.invalidateQueries({ exact: true, queryKey }))); + + return true; + }, + // One active-range reconciliation per listing, retained total, and range. A persistent disagreement therefore + // cannot trigger an invalidation loop. Agreement advances the generation so a later same-total conflict can retry. + queryKey: [ + 'gallery', + 'items', + 'page-total-reconciliation', + filterIdentity, + stableTotal, + currentPageTotalReconciliation.generation, + observedPageOptions.map(({ queryKey }) => queryKey[6]), + ], + staleTime: Infinity, + }); + + // Keep this listing's total for the hook lifetime after its count/page Query data leaves cache. Active page totals + // replace it only after loaded pages settle and agree. + const currentTotal = + observedTotal ?? (isPaginated && isInitialTotalDiscovery && !isFetchingTotal ? queriedTotal : undefined); + if ( + sparseViewport && + currentTotal !== undefined && + (knownTotalSnapshot?.filterIdentity !== filterIdentity || knownTotalSnapshot.total !== currentTotal) + ) { + setKnownTotalSnapshot({ filterIdentity, total: currentTotal }); + } const itemsOptions = galleryItemsInfiniteOptions( filter, // Infinite page values anchor deep reveals; board/search/view changes reset them to zero. @@ -227,30 +576,131 @@ export const useGalleryData = ({ refetch, } = useInfiniteQuery({ ...itemsOptions, - ...(keepPreviousScope ? { placeholderData: keepPreviousData } : {}), + enabled: !sparseViewport, + ...(keepPreviousScope && !sparseViewport ? { placeholderData: keepPreviousData } : {}), }); + const pageItemsByOffset = useMemo( + () => new Map(pageOffsets.map((offset, index) => [offset, pageResults[index]?.data?.items ?? []])), + [pageOffsets, pageResults] + ); // Query forgets which fetch failed as soon as any other starts, so an unrelated refetch (an invalidation after a // generation) would turn a failed next page into a "refresh" failure and let scrolling silently retry it. Keep // the page count the failure left this scope at: the next page stays failed until a Retry, a page beyond it, or // another scope. Recorded from the fetch's own result, in the handler that started it. const [failedNextPage, setFailedNextPage] = useState<{ pageCount: number; scopeHash: string } | null>(null); // Everything below describes this scope; another scope's placeholder is only ever `previousScopeItems`. - const queryData = isPlaceholderData ? undefined : data; + const queryData = sparseViewport || isPlaceholderData ? undefined : data; const backendItems = useMemo(() => { - if (!isPaginated) { + if (!sparseViewport) { return flattenGalleryItemsData(queryData); } - const pageOffset = page * GALLERY_PAGE_SIZE; - const pageIndex = queryData?.pageParams.indexOf(pageOffset) ?? -1; + if (isPaginated) { + return pageItemsByOffset.get(selectedPageOffset) ?? []; + } - return pageIndex === -1 ? [] : (queryData?.pages[pageIndex]?.items ?? []).slice(0, GALLERY_PAGE_SIZE); - }, [isPaginated, page, queryData]); + return [...pageItemsByOffset.entries()].sort(([left], [right]) => left - right).flatMap(([, items]) => items); + }, [isPaginated, pageItemsByOffset, queryData, selectedPageOffset, sparseViewport]); + const backendItemKeys = useMemo(() => new Set(backendItems.map(toGalleryItemKey)), [backendItems]); + const eligibleRecentKeys = useMemo( + () => + new Set>( + recentImages + .slice(0, GALLERY_RECENT_IMAGE_LIMIT) + .map(legacyGeneratedImageToGalleryItem) + .filter((item) => isRecentItemVisible(item, filter)) + .map(toGalleryItemKey) + ), + [filter, recentImages] + ); + const [authoritativeRecentSnapshot, setAuthoritativeRecentSnapshot] = useState<{ + filterIdentity: string; + keys: Set>; + }>({ filterIdentity, keys: new Set() }); + const reconciledBackendRecentKeys = useMemo(() => { + const knownKeys = + authoritativeRecentSnapshot.filterIdentity === filterIdentity + ? authoritativeRecentSnapshot.keys + : new Set>(); + const keys = new Set([...knownKeys].filter((key) => eligibleRecentKeys.has(key))); + + for (const key of eligibleRecentKeys) { + if (backendItemKeys.has(key)) { + keys.add(key); + } + } + + return keys; + }, [authoritativeRecentSnapshot, backendItemKeys, eligibleRecentKeys, filterIdentity]); + if ( + authoritativeRecentSnapshot.filterIdentity !== filterIdentity || + reconciledBackendRecentKeys.size !== authoritativeRecentSnapshot.keys.size || + [...reconciledBackendRecentKeys].some((key) => !authoritativeRecentSnapshot.keys.has(key)) + ) { + setAuthoritativeRecentSnapshot({ filterIdentity, keys: reconciledBackendRecentKeys }); + } + const itemSlots = useMemo( + () => + sparseViewport + ? mapGalleryItemPageSlots({ + pageLocalOffset: isPaginated ? selectedPageOffset : undefined, + pageOffsets, + pages: pageResults.map((result) => result.data), + }) + : new Map(), + [isPaginated, pageOffsets, pageResults, selectedPageOffset, sparseViewport] + ); // Recents belong at the top of the listing; overlaying them onto a window // anchored mid-board would sort them into a part of the list they are // nowhere near. const shouldOverlayRecentItems = !isPaginated && page === 0; const maxRows = isPaginated ? GALLERY_PAGE_SIZE : GALLERY_MAX_ROWS; + const recentItems = useMemo( + () => + sparseViewport && shouldOverlayRecentItems + ? getGalleryRecentItems({ + backendItems, + filter, + knownBackendItemKeys: reconciledBackendRecentKeys, + recentImages, + }) + : [], + [backendItems, filter, recentImages, reconciledBackendRecentKeys, shouldOverlayRecentItems, sparseViewport] + ); + const sparseItems = useMemo( + () => + sparseViewport + ? pageResults.some((result) => result.data) || recentItems.length > 0 || knownTotal === 0 + ? mergeGalleryItemWindow({ + backendItems, + filter, + knownBackendItemKeys: reconciledBackendRecentKeys, + maxRows: Math.max(GALLERY_MAX_ROWS, backendItems.length + GALLERY_RECENT_IMAGE_LIMIT), + recentImages: shouldOverlayRecentItems ? recentImages : [], + }) + : null + : queryData || (shouldOverlayRecentItems && recentImages.length > 0) + ? mergeGalleryItemWindow({ + backendItems, + filter, + maxRows, + recentImages: shouldOverlayRecentItems ? recentImages : [], + }) + : null, + [ + backendItems, + filter, + knownTotal, + maxRows, + pageResults, + queryData, + recentItems.length, + recentImages, + reconciledBackendRecentKeys, + shouldOverlayRecentItems, + sparseViewport, + ] + ); const scopedItems = useMemo( () => mergeGalleryItemWindow({ @@ -264,7 +714,7 @@ export const useGalleryData = ({ const isNextPageFailed = isFetchNextPageError || (failedNextPage?.scopeHash === scopeHash && failedNextPage.pageCount === (queryData?.pages.length ?? 0)); - const { items, status } = useMemo( + const { items: denseItems, status: denseStatus } = useMemo( () => getGalleryListing( { errorUpdateCount, hasData: queryData !== undefined, isError, isFetchNextPageError: isNextPageFailed }, @@ -273,18 +723,21 @@ export const useGalleryData = ({ [errorUpdateCount, isError, isNextPageFailed, queryData, scopedItems] ); const previousScopeItems = useMemo( - () => (status === 'loading' && isPlaceholderData && data ? flattenGalleryItemsData(data).slice(0, maxRows) : null), - [data, isPlaceholderData, maxRows, status] + () => + denseStatus === 'loading' && isPlaceholderData && data ? flattenGalleryItemsData(data).slice(0, maxRows) : null, + [data, denseStatus, isPlaceholderData, maxRows] ); - const total = queryData?.pages[0]?.total ?? null; - const hasMore = !isPaginated && queryData !== undefined && Boolean(hasNextPage); - const isWindowTruncated = isGalleryWindowTruncated({ - hasNextPage: Boolean(hasNextPage), - isPaginated, - loadedRowCount: backendItems.length, - maxRows, - total, - }); + const total = sparseViewport ? (observedTotal ?? stableTotal) : (queryData?.pages[0]?.total ?? null); + const hasMore = !sparseViewport && !isPaginated && queryData !== undefined && Boolean(hasNextPage); + const isWindowTruncated = + !sparseViewport && + isGalleryWindowTruncated({ + hasNextPage: Boolean(hasNextPage), + isPaginated, + loadedRowCount: backendItems.length, + maxRows, + total, + }); const fetchMore = useCallback(async () => { const result = await fetchNextPage(); @@ -293,6 +746,7 @@ export const useGalleryData = ({ } }, [fetchNextPage, scopeHash]); // A failed page waits for an explicit Retry; scrolling near the end must not hammer it. + const items = sparseViewport ? sparseItems : denseItems; const loadMore = useCallback(() => { if (!hasMore || isFetchingNextPage || isNextPageFailed) { return; @@ -300,40 +754,143 @@ export const useGalleryData = ({ void fetchMore(); }, [fetchMore, hasMore, isFetchingNextPage, isNextPageFailed]); + const sparseListing = useMemo(() => { + if (!sparseViewport) { + return undefined; + } + + const pageStates = new Map(); + + pageOffsets.forEach((offset, index) => { + const result = pageResults[index]; + + if (result) { + pageStates.set(offset, { + error: result.error ?? retryingSparseErrors.get(getSparsePageErrorKey(offset)) ?? null, + isLoading: result.isFetching, + retry: () => + retrySparsePage( + offset, + () => result.refetch(), + result.error ?? retryingSparseErrors.get(getSparsePageErrorKey(offset)) ?? null + ), + }); + } + }); + + if (hasUnresolvedTotalError) { + pageStates.set(0, { + error: totalError, + isLoading: isFetchingTotal, + retry: () => refetchTotal(), + }); + } + + return { itemSlots, pageStates, recentItems, total }; + }, [ + itemSlots, + hasUnresolvedTotalError, + pageOffsets, + pageResults, + getSparsePageErrorKey, + recentItems, + retrySparsePage, + retryingSparseErrors, + sparseViewport, + total, + totalError, + isFetchingTotal, + refetchTotal, + ]); + const firstPageError = sparseListing?.pageStates.get(0)?.error ?? null; + const pageErrors = pageResults.flatMap((result, index) => { + const offset = pageOffsets[index]; + + if (offset === undefined) { + return []; + } + + const error = result.error ?? retryingSparseErrors.get(getSparsePageErrorKey(offset)) ?? null; + + return error ? [{ error, offset, result }] : []; + }); + const pageError = pageErrors[0]?.error ?? null; + const activePageLoading = pageResults.some((result) => result.isFetching) || isFetchingTotal; // The observer re-runs whatever its current key needs, so a retry started in one scope never lands in another. - const retry = useCallback(async () => { - if (status !== 'more-error') { + const denseRetry = useCallback(async () => { + if (denseStatus !== 'more-error') { await refetch(); return; } setFailedNextPage(null); await fetchMore(); - }, [fetchMore, refetch, status]); - const isFailed = status === 'error' || status === 'stale-error'; - const listing = useMemo( + }, [denseStatus, fetchMore, refetch]); + const isDenseFailed = denseStatus === 'error' || denseStatus === 'stale-error'; + const denseListing = useMemo( () => ({ error, isFetchingMore: isFetchingNextPage, // An unrelated refetch is not a retry of the failed page. - isRetrying: status === 'more-error' ? isFetchingNextPage : isFailed && isFetching, - retry, - status, + isRetrying: denseStatus === 'more-error' ? isFetchingNextPage : isDenseFailed && isFetching, + retry: denseRetry, + status: denseStatus, + }), + [denseStatus, denseRetry, error, isDenseFailed, isFetching, isFetchingNextPage] + ); + const sparseError = hasUnresolvedTotalError ? totalError : pageError; + const sparseHasData = pageResults.some((result) => result.data !== undefined) || knownTotal !== null; + const sparseErrorPageOffset = hasUnresolvedTotalError ? 0 : (pageErrors[0]?.offset ?? 0); + const sparseReadStatus = getGalleryReadStatus( + { errorUpdateCount: sparseError ? 1 : 0, hasData: sparseHasData, isError: sparseError !== null }, + sparseItems?.length ?? 0 + ); + // A failed page beyond the first page is a local continuation error. The grid owns its retry control; the shared + // status still announces the failure as "more-error" without suggesting that earlier results are stale. + const sparseStatus = + sparseError !== null && sparseHasData && sparseErrorPageOffset > 0 ? 'more-error' : sparseReadStatus; + const retrySparse = useCallback(async () => { + if (hasUnresolvedTotalError) { + await refetchTotal(); + return; + } + + const failedPage = pageErrors[0]; + + if (failedPage) { + await retrySparsePage(failedPage.offset, () => failedPage.result.refetch(), failedPage.error); + } + }, [hasUnresolvedTotalError, pageErrors, refetchTotal, retrySparsePage]); + const sparseListingState = useMemo( + () => ({ + error: sparseError, + isFetchingMore: pageResults.some((result) => result.isFetching), + isRetrying: + sparseError !== null && + (hasUnresolvedTotalError ? isFetchingTotal : pageErrors.some(({ result }) => result.isFetching)), + retry: retrySparse, + status: sparseStatus, }), - [error, isFailed, isFetching, isFetchingNextPage, retry, status] + [hasUnresolvedTotalError, isFetchingTotal, pageErrors, pageResults, retrySparse, sparseError, sparseStatus] ); + const listing = sparseViewport ? sparseListingState : denseListing; return { boards, boardsState, filter, hasMore, + isLoadingItems: sparseViewport ? activePageLoading : isFetching, isWindowTruncated, items, listing, loadMore, + queryError: sparseViewport ? (pageError ?? firstPageError ?? (hasUnresolvedTotalError ? totalError : null)) : error, previousScopeItems, selectedBoardId: boardId, + pinRevealIndex: sparseViewport && !isPaginated ? pinRevealIndex : undefined, + setVisibleRange: sparseViewport ? setVisibleRange : undefined, + sparseListing, total, }; }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridHotkeys.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridHotkeys.ts index 4a363dd92a9..dcff983b72f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridHotkeys.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridHotkeys.ts @@ -1,7 +1,7 @@ import type { GalleryItem, GalleryItemKey, GalleryItemRef } from '@features/gallery/core/items'; import type { GalleryNavigationDirection, GalleryNavigationEntry } from '@features/gallery/core/selection'; -import { shouldStarSelection, toGalleryItemKey, toGalleryItemRef } from '@features/gallery/core/items'; +import { toGalleryItemKey, toGalleryItemRef } from '@features/gallery/core/items'; import { getGalleryNavigationCursor, getGalleryNavigationStep } from '@features/gallery/core/selection'; import { useEffect, useEffectEvent, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,13 @@ import { useGalleryWidget } from './GalleryWidgetContext'; * How an arrow moves: `select` replaces the selection with the next tile, `extend` selects the range from the anchor * to it, and `focus` moves keyboard focus alone, so a toggle can then build a discontiguous selection. */ -type GalleryNavigationMode = 'extend' | 'focus' | 'select'; +export type GalleryNavigationMode = 'extend' | 'focus' | 'select'; + +export interface GalleryUnloadedNavigationRequest { + anchorKey: GalleryItemKey | null; + mode: GalleryNavigationMode; + onResolved: (itemKey: GalleryItemKey) => void; +} const GALLERY_HOTKEYS = [ ['gallery.selectAllOnPage', 'widgets.gallery.commands.selectAllOnPage', null, ['mod+a']], @@ -71,10 +77,13 @@ export const useGalleryGridHotkeys = ({ getDialogReturnFocus, getFirstVisibleTileKey, getFocusedItem, - loadedItems, moveToEntry, navigationSections, + navigateToUnloadedSlot, + getSelectionPage, + onNavigationStart, selectItemRange, + shouldStar, toggleItem, }: { actionSelectionRefs: GalleryItemRef[]; @@ -90,8 +99,6 @@ export const useGalleryGridHotkeys = ({ getFirstVisibleTileKey: () => string | null; /** The thumbnail holding keyboard focus, if any. */ getFocusedItem: () => GalleryItem | null; - /** Everything on hand for star-state lookups, strip included. */ - loadedItems: readonly GalleryItem[]; /** * Applies `select` (none for a focus-only move), brings the entry's tile into view, and moves keyboard focus there * when the grid holds it. @@ -99,7 +106,18 @@ export const useGalleryGridHotkeys = ({ moveToEntry: (entry: GalleryNavigationEntry, select: (() => void) | null) => void; /** The arrow-key sections in visual order: the starred strip, in progress, the listing. */ navigationSections: readonly (readonly GalleryNavigationEntry[])[]; - selectItemRange: (item: GalleryItem, anchorKey: GalleryItemKey | null) => Promise; + /** Loads a sparse absolute slot; it becomes selectable after its page hydrates. */ + navigateToUnloadedSlot?: (absoluteIndex: number, request: GalleryUnloadedNavigationRequest) => void; + /** Supersedes any unfinished sparse navigation before handling this newer arrow command. */ + onNavigationStart?: () => void; + /** The sparse page stamp for a loaded listing item. */ + getSelectionPage?: (item: GalleryItem) => number | undefined; + selectItemRange: ( + item: GalleryItem, + options?: { anchorKey?: GalleryItemKey | null; selectionPage?: number } + ) => Promise; + /** Whether the star command stars the action selection: some of it is not starred. */ + shouldStar: boolean; toggleItem: (item: GalleryItem) => void; }) => { const { t } = useTranslation(); @@ -108,7 +126,16 @@ export const useGalleryGridHotkeys = ({ // A run of Shift+arrows keeps the anchor it started from; the range it last reached says whether it is still running. const keyboardRangeRef = useRef<{ anchorKey: GalleryItemKey | null; reachedKey: GalleryItemKey } | null>(null); + // A run continues from its own anchor. A new one starts from the persisted primary, which outlives its tile when + // a sparse page leaves the viewport; a stored null would let the next step re-anchor on the moved primary. + const getRangeAnchorKey = (cursorKey: string | null): GalleryItemKey | null => { + const range = keyboardRangeRef.current; + + return range && range.reachedKey === cursorKey ? range.anchorKey : gallery.primarySelectedItemKey; + }; + const navigate = useEffectEvent((direction: GalleryNavigationDirection, mode: GalleryNavigationMode) => { + onNavigationStart?.(); const cursorKey = getGalleryNavigationCursor(navigationSections, getCursorCandidates()); // Ranges and focus moves step between items only: in-progress sessions are followed, never selected. They stay in // the sections, since one can be where the step starts. @@ -128,13 +155,36 @@ export const useGalleryGridHotkeys = ({ return; } + const unloadedSlotMatch = entry.kind === 'slot' ? /^gallery-unloaded-slot:(\d+)$/.exec(entry.id) : null; + + if (unloadedSlotMatch) { + const anchorKey = mode === 'extend' ? getRangeAnchorKey(cursorKey) : null; + + navigateToUnloadedSlot?.(Number(unloadedSlotMatch[1]), { + anchorKey, + mode, + onResolved: (itemKey) => { + keyboardRangeRef.current = mode === 'extend' ? { anchorKey, reachedKey: itemKey } : null; + }, + }); + return; + } + + if (entry.kind === 'slot') { + return; + } + if (entry.kind === 'session') { moveToEntry(entry, () => followProgressSession(entry.id, { revealPreview: false })); return; } if (mode === 'select') { - moveToEntry(entry, () => actions.selectItem(entry.item)); + const selectionPage = getSelectionPage?.(entry.item); + + moveToEntry(entry, () => + selectionPage === undefined ? actions.selectItem(entry.item) : actions.selectItem(entry.item, selectionPage) + ); return; } @@ -143,19 +193,26 @@ export const useGalleryGridHotkeys = ({ return; } - const range = keyboardRangeRef.current; - const anchorKey = range && range.reachedKey === cursorKey ? range.anchorKey : gallery.selectedItemKey; + const anchorKey = getRangeAnchorKey(cursorKey); keyboardRangeRef.current = { anchorKey, reachedKey: toGalleryItemKey(entry.item) }; - moveToEntry(entry, () => void selectItemRange(entry.item, anchorKey)); + moveToEntry( + entry, + () => void selectItemRange(entry.item, { anchorKey, selectionPage: getSelectionPage?.(entry.item) }) + ); }); const executeGalleryHotkey = useEffectEvent((commandId: string) => { if (commandId === 'gallery.selectAllOnPage') { const primaryItem = gallery.items[0]; + const selectionPage = primaryItem ? getSelectionPage?.(primaryItem) : undefined; if (primaryItem) { - actions.selectItemRange(gallery.items.map(toGalleryItemRef), primaryItem); + if (selectionPage === undefined) { + actions.selectItemRange(gallery.items.map(toGalleryItemRef), primaryItem); + } else { + actions.selectItemRange(gallery.items.map(toGalleryItemRef), primaryItem, selectionPage); + } } return; } @@ -180,7 +237,7 @@ export const useGalleryGridHotkeys = ({ } if (commandId === 'gallery.starImage' && actionSelectionRefs.length > 0) { - void itemActions.setItemsStarred(actionSelectionRefs, shouldStarSelection(loadedItems, actionSelectionRefs)); + void itemActions.setItemsStarred(actionSelectionRefs, shouldStar); return; } diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridSelection.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridSelection.ts index 8db48da8713..051fb75a75e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridSelection.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryGridSelection.ts @@ -6,15 +6,16 @@ import { toGalleryItemKey, toGalleryItemRef, } from '@features/gallery/core/items'; -import { isDateBoardId, type GalleryItemNames } from '@features/gallery/data/backend'; +import { getGalleryItemByRef, isDateBoardId, type GalleryItemNames } from '@features/gallery/data/backend'; import { galleryItemNamesOptions } from '@features/gallery/data/queries'; +import { useMountEffect } from '@platform/react/useMountEffect'; import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react'; import type { GalleryItemContextMenuTarget } from './GalleryUiContext'; -import { useGalleryWidget } from './GalleryWidgetContext'; +import { useGallerySelectionStarred, useGalleryWidget } from './GalleryWidgetContext'; const getGalleryItemRange = ( orderedRefs: readonly GalleryItemRef[], @@ -38,7 +39,12 @@ const getGalleryItemRange = ( * Async range selection fetches beyond the loaded window; apply only if account, filter, and anchor still match * the captured context. */ -export const useGalleryGridSelection = () => { +export const useGalleryGridSelection = ({ + getSelectionPage, +}: { + /** The sparse page stamp for a loaded listing item; selections without one stamp the grid's page. */ + getSelectionPage?: (item: GalleryItem) => number | undefined; +} = {}) => { // `loadedItems` includes the strip, whose starred items the listing window // may not hold; the context menu and ctrl-toggle must resolve those too. const { actions, filter, gallery, loadedItems, starredStrip } = useGalleryWidget(); @@ -49,15 +55,30 @@ export const useGalleryGridSelection = () => { const selectedItemRefs = useMemo(() => gallery.selectedItemKeys.map(parseGalleryItemKey), [gallery.selectedItemKeys]); const filterIdentity = useMemo(() => JSON.stringify(filter), [filter]); - const rangeInteractionContextRef = useRef({ filterIdentity, selectedItemKey: gallery.selectedItemKey }); + const selectionIdentity = gallery.selectedItemKeys.join('\n'); + const rangeInteractionContextRef = useRef({ + filterIdentity, + selectedItemKey: gallery.primarySelectedItemKey, + selectionIdentity, + }); + + // Next-primary lookups end with the grid; a late result could otherwise toggle a selection changed elsewhere. + const nextPrimaryLookupsRef = useRef(new Set()); + useMountEffect(() => () => { + nextPrimaryLookupsRef.current.forEach((controller) => controller.abort()); + }); const syncRangeInteractionContext = useCallback( (node: HTMLDivElement | null) => { if (node) { - rangeInteractionContextRef.current = { filterIdentity, selectedItemKey: gallery.selectedItemKey }; + rangeInteractionContextRef.current = { + filterIdentity, + selectedItemKey: gallery.primarySelectedItemKey, + selectionIdentity, + }; } }, - [filterIdentity, gallery.selectedItemKey] + [filterIdentity, gallery.primarySelectedItemKey, selectionIdentity] ); const activeContextMenuTarget = useMemo(() => { @@ -74,21 +95,49 @@ export const useGalleryGridSelection = () => { /** Selects from the anchor (the primary selection unless a keyboard range names its own) through `item`. */ const selectItemRange = useCallback( - async (item: GalleryItem, anchorKey?: GalleryItemKey | null) => { + async ( + item: GalleryItem, + { + anchorKey, + isFocusCurrent, + isNavigationCurrent, + selectionPage, + }: { + anchorKey?: GalleryItemKey | null; + isFocusCurrent?: () => boolean; + isNavigationCurrent?: () => boolean; + selectionPage?: number; + } = {} + ) => { const owner = captureAccountScope(); const capturedContext = rangeInteractionContextRef.current; const anchorItemKey = anchorKey ?? capturedContext.selectedItemKey; const targetItemKey = toGalleryItemKey(item); + const isInteractionContextCurrent = () => + isAccountScopeCurrent(owner) && + rangeInteractionContextRef.current.filterIdentity === capturedContext.filterIdentity && + rangeInteractionContextRef.current.selectedItemKey === capturedContext.selectedItemKey; + const isInteractionCurrent = (requireFocusedTarget = false) => + isNavigationCurrent?.() !== false && + (!requireFocusedTarget || isFocusCurrent?.() !== false) && + isInteractionContextCurrent(); + const selectSingleItem = () => { + if (selectionPage === undefined) { + actions.selectItem(item); + } else { + actions.selectItem(item, selectionPage); + } + }; + + if (!isInteractionContextCurrent()) { + return; + } if (!anchorItemKey) { - actions.selectItem(item); + selectSingleItem(); return; } - const isInteractionCurrent = () => - isAccountScopeCurrent(owner) && - rangeInteractionContextRef.current.filterIdentity === capturedContext.filterIdentity && - rangeInteractionContextRef.current.selectedItemKey === capturedContext.selectedItemKey; const selectFromRefs = (refs: readonly GalleryItemRef[]): boolean => { const range = getGalleryItemRange(refs, anchorItemKey, targetItemKey); @@ -96,18 +145,29 @@ export const useGalleryGridSelection = () => { return false; } - actions.selectItemRange(range, item); + if (selectionPage === undefined) { + actions.selectItemRange(range, item); + } else { + actions.selectItemRange(range, item, selectionPage); + } return true; }; const materializedRefs = gallery.items.map(toGalleryItemRef); const namesOptions = galleryItemNamesOptions(filter); + const usesSynchronousNames = isDateBoardId(filter.boardId); + let hasAwaitedNames = false; try { - const orderedRefs = isDateBoardId(filter.boardId) - ? queryClient.getQueryData(namesOptions.queryKey)?.items - : (await queryClient.fetchQuery(namesOptions)).items; + let orderedRefs: readonly GalleryItemRef[] | undefined; + if (usesSynchronousNames) { + orderedRefs = queryClient.getQueryData(namesOptions.queryKey)?.items; + } else { + const namesPromise = queryClient.fetchQuery(namesOptions); + hasAwaitedNames = true; + orderedRefs = (await namesPromise).items; + } - if (!isInteractionCurrent()) { + if (!isInteractionCurrent(hasAwaitedNames)) { return; } @@ -115,40 +175,87 @@ export const useGalleryGridSelection = () => { return; } } catch { - if (!isInteractionCurrent()) { + if (!isInteractionCurrent(hasAwaitedNames)) { return; } } // The names list describes the listing only; a range inside the strip // resolves against the strip's own order. + if (!isInteractionCurrent(hasAwaitedNames)) { + return; + } + if (!selectFromRefs(materializedRefs) && !selectFromRefs(starredStrip.items.map(toGalleryItemRef))) { - actions.selectItem(item); + selectSingleItem(); } }, [actions, filter, gallery.items, queryClient, starredStrip.items] ); const toggleItem = useCallback( - (item: GalleryItem) => { + (item: GalleryItem, itemSelectionPage = getSelectionPage?.(item)) => { const itemKey = toGalleryItemKey(item); const remainingItemKeys = gallery.selectedItemKeys.filter((key) => key !== itemKey); - const nextPrimaryItem = - gallery.selectedItemKey === itemKey - ? (loadedItems.find( - (candidate) => toGalleryItemKey(candidate) === remainingItemKeys[remainingItemKeys.length - 1] - ) ?? null) - : null; - - actions.toggleItemInSelection(item, nextPrimaryItem); + const isPrimary = gallery.selectedItemKey === itemKey; + const nextPrimaryKey = isPrimary ? (remainingItemKeys.at(-1) ?? null) : null; + const loadedNextPrimary = + nextPrimaryKey === null + ? null + : (loadedItems.find((candidate) => toGalleryItemKey(candidate) === nextPrimaryKey) ?? null); + const toggle = (nextPrimaryItem: GalleryItem | null) => { + // Stamp whichever item becomes primary where it sits, as a click or range does. + const selectionPage = isPrimary + ? nextPrimaryItem + ? getSelectionPage?.(nextPrimaryItem) + : undefined + : itemSelectionPage; + + if (selectionPage === undefined) { + actions.toggleItemInSelection(item, nextPrimaryItem); + } else { + actions.toggleItemInSelection(item, nextPrimaryItem, selectionPage); + } + }; + + if (nextPrimaryKey === null || loadedNextPrimary) { + toggle(loadedNextPrimary); + return; + } + + // The next primary's page has left the viewport. Without its item the toggle clears the whole selection, so + // resolve it first. A lookup that fails (the item was deleted elsewhere, or the request failed) still toggles + // rather than ignoring the click. + const owner = captureAccountScope(); + const capturedContext = rangeInteractionContextRef.current; + const controller = new AbortController(); + const signal = AbortSignal.any([owner.signal, controller.signal]); + const toggleIfCurrent = (nextPrimaryItem: GalleryItem | null) => { + const current = rangeInteractionContextRef.current; + + if ( + !signal.aborted && + isAccountScopeCurrent(owner) && + current.filterIdentity === capturedContext.filterIdentity && + current.selectedItemKey === capturedContext.selectedItemKey && + current.selectionIdentity === capturedContext.selectionIdentity + ) { + toggle(nextPrimaryItem); + } + }; + + nextPrimaryLookupsRef.current.add(controller); + void getGalleryItemByRef(parseGalleryItemKey(nextPrimaryKey), signal) + .then(toggleIfCurrent, () => toggleIfCurrent(null)) + .finally(() => nextPrimaryLookupsRef.current.delete(controller)); }, - [actions, gallery.selectedItemKey, gallery.selectedItemKeys, loadedItems] + [actions, gallery.selectedItemKey, gallery.selectedItemKeys, getSelectionPage, loadedItems] ); const handleThumbnailClick = useCallback( - (item: GalleryItem, event: MouseEvent) => { + (item: GalleryItem, event: MouseEvent, selectionPage?: number) => { if (event.shiftKey) { - void selectItemRange(item); + void selectItemRange(item, { selectionPage }); return; } @@ -158,14 +265,30 @@ export const useGalleryGridSelection = () => { } if (event.ctrlKey || event.metaKey) { - toggleItem(item); + toggleItem(item, selectionPage); } else { - actions.selectItem(item); + if (selectionPage === undefined) { + actions.selectItem(item); + } else { + actions.selectItem(item, selectionPage); + } } }, [actions, selectItemRange, toggleItem] ); + /** Falls back to the primary selection so hotkeys work before a multi-select. */ + const actionSelectionRefs = useMemo( + () => + selectedItemRefs.length > 0 + ? selectedItemRefs + : gallery.selectedItemKey + ? [parseGalleryItemKey(gallery.selectedItemKey)] + : [], + [gallery.selectedItemKey, selectedItemRefs] + ); + const shouldStarSelection = useGallerySelectionStarred(actionSelectionRefs, loadedItems); + const handleThumbnailContextMenu = useCallback( (item: GalleryItem, x: number, y: number) => { const itemKey = toGalleryItemKey(item); @@ -178,13 +301,20 @@ export const useGalleryGridSelection = () => { ), ]; - setContextMenuTarget({ itemRefs: selectedItemRefs, items: selectionItems, x, y }); + // The menu sees only loaded items; the star answer also covers selected items no page holds. + setContextMenuTarget({ + allStarred: !shouldStarSelection, + itemRefs: selectedItemRefs, + items: selectionItems, + x, + y, + }); return; } setContextMenuTarget({ itemRefs: [toGalleryItemRef(item)], items: [item], x, y }); }, - [loadedItems, selectedItemKeys, selectedItemRefs] + [loadedItems, selectedItemKeys, selectedItemRefs, shouldStarSelection] ); const getDragItems = useCallback( @@ -202,17 +332,6 @@ export const useGalleryGridSelection = () => { const handleCloseContextMenu = useCallback(() => setContextMenuTarget(null), []); - /** Falls back to the primary selection so hotkeys work before a multi-select. */ - const actionSelectionRefs = useMemo( - () => - selectedItemRefs.length > 0 - ? selectedItemRefs - : gallery.selectedItemKey - ? [parseGalleryItemKey(gallery.selectedItemKey)] - : [], - [gallery.selectedItemKey, selectedItemRefs] - ); - return { actionSelectionRefs, activeContextMenuTarget, @@ -223,6 +342,7 @@ export const useGalleryGridSelection = () => { loadedItems, selectedItemKeys, selectItemRange, + shouldStarSelection, syncRangeInteractionContext, toggleItem, }; diff --git a/invokeai/frontend/webv2/src/features/gallery/utility.ts b/invokeai/frontend/webv2/src/features/gallery/utility.ts index 4c5081ea973..963b39a888d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/utility.ts +++ b/invokeai/frontend/webv2/src/features/gallery/utility.ts @@ -36,3 +36,5 @@ export const galleryVideoUrls = { full: getGalleryVideoFullUrl, thumbnail: getGalleryVideoThumbnailUrl, } as const; + +export { abortGalleryLocatorRequests, createGalleryLocatorRequest, type GalleryLocatorRequest } from './core/selection'; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx index 213c6dc3071..8b7e9079d3f 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx @@ -605,6 +605,19 @@ describe('ImageContextMenu mixed-media action visibility', () => { expect(document.body.textContent).not.toContain('widgets.canvas.import.newProjectFromImage'); }); + it("offers Unstar All from the host's star answer for a selection with unloaded members", async () => { + const loadedImage = { ...item('image', 'starred-still.png'), starred: true }; + const actions = createActions(vi.fn()); + const refs = [ + { kind: 'image' as const, name: loadedImage.name }, + { kind: 'image' as const, name: 'starred-unloaded.png' }, + ]; + await renderItemMenu(actions, { allStarred: true, itemRefs: refs, items: [loadedImage], x: 20, y: 20 }); + + await interact(() => getMenuItem('Unstar All').click()); + expect(actions.setItemsStarred).toHaveBeenCalledWith(refs, false); + }); + it('keeps complete mixed refs for common bulk actions and hides image-only bulk actions when a ref is unresolved', async () => { const loadedImage = item('image', 'still.png'); const actions = createActions(vi.fn()); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 0e40882eeb4..6813bdf5eb5 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -266,6 +266,7 @@ const GalleryItemContextMenuContent = ({ {isBulk ? ( void; }) => { - const allStarred = loadedItems.length === itemRefs.length && loadedItems.every((item) => item.starred); + const allStarred = + knownAllStarred ?? (loadedItems.length === itemRefs.length && loadedItems.every((item) => item.starred)); const handleOpenInNewTab = useCallback(() => actions.openItemInNewTab(primaryItem), [actions, primaryItem]); const handleOpenPreview = useCallback(() => actions.openItemInPreview(primaryItem), [actions, primaryItem]); const handleToggleStarred = useCallback( diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts new file mode 100644 index 00000000000..41c87d4d74f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.test.ts @@ -0,0 +1,199 @@ +import type { GalleryItem } from '@features/gallery/core/items'; +import type { QueryClient } from '@tanstack/react-query'; +import type { WorkbenchCommands, WorkbenchQueries } from '@workbench/workbenchStore'; + +import { claimGalleryNavigationSequence } from '@features/gallery/contracts'; +import { captureAccountScope, accountLifecycle } from '@platform/state/accountLifecycle'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GalleryRevealTicket } from './revealGalleryItem'; + +const mocks = vi.hoisted(() => ({ + fetchVerifiedPage: vi.fn(), + galleryValues: {} as Record, + resolve: vi.fn(), + settings: { + boardOrderBy: 'created_at', + boardOrderDir: 'DESC', + imageOrderDir: 'DESC', + paginationMode: 'paginated', + showArchivedBoards: false, + showDateBoards: false, + } as Record, + requestReveal: vi.fn(), +})); + +vi.mock('@features/gallery', () => ({ + galleryItems: { resolve: mocks.resolve }, + toGalleryItemKey: (ref: { kind: string; name: string }) => `${ref.kind}:${ref.name}`, +})); + +vi.mock('@features/gallery/contracts', async (importOriginal) => ({ + ...(await importOriginal>()), + getGallerySettings: () => mocks.settings, + requestGalleryItemReveal: mocks.requestReveal, +})); + +vi.mock('@features/gallery/queries', () => ({ + GALLERY_PAGE_SIZE: 60, + fetchVerifiedGalleryItemPage: (...args: unknown[]) => mocks.fetchVerifiedPage(...args), + galleryBoardsOptions: (query: unknown) => ({ kind: 'boards', query }), +})); + +vi.mock('@workbench/widgetState', () => ({ getProjectWidgetValues: () => mocks.galleryValues })); + +import { revealGalleryItem } from './revealGalleryItem'; + +const item: GalleryItem = { + boardId: 'board-1', + category: 'general', + createdAt: '2026-07-15T12:00:00Z', + fullUrl: '/images/target.png', + height: 64, + isIntermediate: false, + kind: 'image', + name: 'target.png', + starred: false, + thumbnailUrl: '/images/target.webp', + width: 64, +}; + +const queryClient = { + fetchQuery: vi.fn().mockResolvedValue([{ id: 'board-1' }]), +} as unknown as QueryClient; + +const createContext = () => { + const commands = { + gallery: { + selectBoard: vi.fn(), + selectItem: vi.fn(), + setPage: vi.fn(), + setView: vi.fn(), + }, + widgets: { patchValues: vi.fn() }, + } as unknown as WorkbenchCommands; + const queries = { + getSnapshot: () => ({ activeProject: { id: 'project-1' } }), + isActiveProject: (projectId: string) => projectId === 'project-1', + } as unknown as WorkbenchQueries; + + return { context: { commands, queryClient, queries }, commands }; +}; + +const ticket = (): GalleryRevealTicket => ({ + accountScope: captureAccountScope(), + locatorSignal: new AbortController().signal, + projectId: 'project-1', + sequence: claimGalleryNavigationSequence(), +}); + +beforeEach(() => { + accountLifecycle.activate('gallery-reveal-test'); + mocks.fetchVerifiedPage + .mockReset() + .mockResolvedValue({ index: 127, offset: 120, page: { items: [item], total: 200 }, total: 200 }); + mocks.galleryValues = {}; + mocks.resolve.mockReset().mockResolvedValue(item); + mocks.settings = { + boardOrderBy: 'created_at', + boardOrderDir: 'DESC', + imageOrderDir: 'DESC', + paginationMode: 'paginated', + showArchivedBoards: false, + showDateBoards: false, + }; + mocks.requestReveal.mockReset(); + vi.mocked(queryClient.fetchQuery) + .mockReset() + .mockResolvedValue([{ id: 'board-1' }]); +}); + +afterEach(() => accountLifecycle.invalidate()); + +describe('revealGalleryItem', () => { + it('verifies the exact ordinary listing page before changing Gallery state and sends the absolute slot to the grid', async () => { + const { commands, context } = createContext(); + const revealTicket = ticket(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, revealTicket); + + expect(mocks.fetchVerifiedPage).toHaveBeenCalledWith( + queryClient, + { boardId: 'board-1', galleryView: 'images', orderDir: 'DESC', searchTerm: '', starred: false }, + { kind: 'image', name: 'target.png' }, + expect.objectContaining({ accountId: 'gallery-reveal-test' }), + revealTicket.locatorSignal + ); + expect(commands.gallery.selectBoard).toHaveBeenCalledWith('board-1'); + expect(commands.gallery.setPage).toHaveBeenCalledWith(2); + expect(commands.gallery.selectItem).toHaveBeenCalledWith(item, 'project-1', 2); + expect(mocks.requestReveal).toHaveBeenCalledWith('image:target.png', revealTicket.accountScope.signal, 127); + }); + + it('keeps selection, filters, board, and scroll unchanged when the one locator retry cannot verify a page', async () => { + mocks.fetchVerifiedPage.mockResolvedValue(null); + mocks.galleryValues = { searchTerm: 'sunset', semanticImageQuery: { kind: 'text', query: 'sunset' } }; + const { commands, context } = createContext(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, ticket()); + + expect(commands.widgets.patchValues).not.toHaveBeenCalled(); + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(commands.gallery.setView).not.toHaveBeenCalled(); + expect(commands.gallery.setPage).not.toHaveBeenCalled(); + expect(commands.gallery.selectItem).not.toHaveBeenCalled(); + expect(mocks.requestReveal).not.toHaveBeenCalled(); + }); + + it('resolves semantic or cluster selections against the ordinary destination listing before clearing the query', async () => { + mocks.galleryValues = { + searchTerm: '', + semanticImageQuery: { clusterId: 'cluster-1', kind: 'cluster', label: 'nearby' }, + semanticSearchText: '', + }; + const { commands, context } = createContext(); + + await revealGalleryItem(context, { kind: 'image', name: item.name }, ticket()); + + expect(mocks.fetchVerifiedPage.mock.calls[0]?.[1]).not.toHaveProperty('semanticQuery'); + expect(commands.widgets.patchValues).toHaveBeenCalledWith('gallery', { + searchTerm: '', + semanticImageQuery: null, + semanticSearchText: null, + starredOnly: false, + }); + expect(commands.gallery.selectItem).toHaveBeenCalledOnce(); + }); + + it('discards a gesture when its captured account epoch changes during media resolution', async () => { + let resolveItem!: (resolved: GalleryItem) => void; + mocks.resolve.mockReturnValue( + new Promise((resolve) => { + resolveItem = resolve; + }) + ); + const { commands, context } = createContext(); + const reveal = revealGalleryItem(context, { kind: 'image', name: item.name }, ticket()); + + accountLifecycle.activate('gallery-reveal-next-account'); + resolveItem(item); + + await expect(reveal).rejects.toThrow('account scope'); + expect(mocks.fetchVerifiedPage).not.toHaveBeenCalled(); + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(commands.gallery.selectItem).not.toHaveBeenCalled(); + }); + + it('does not resolve media when the originating gesture account changed before the reveal chunk runs', async () => { + const { commands, context } = createContext(); + const originatingTicket = ticket(); + + accountLifecycle.activate('gallery-reveal-next-account'); + await revealGalleryItem(context, { kind: 'image', name: item.name }, originatingTicket); + + expect(mocks.resolve).not.toHaveBeenCalled(); + expect(mocks.fetchVerifiedPage).not.toHaveBeenCalled(); + expect(commands.gallery.selectBoard).not.toHaveBeenCalled(); + expect(commands.gallery.selectItem).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts index a4c6306ed01..7915f22d8c8 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/revealGalleryItem.ts @@ -1,18 +1,13 @@ import type { GalleryView } from '@features/gallery'; import type { GalleryItemRef } from '@features/gallery/contracts'; -import type { GalleryItemsFilter } from '@features/gallery/queries'; +import type { AccountScope } from '@platform/state/accountLifecycle'; import type { QueryClient } from '@tanstack/react-query'; import type { WorkbenchCommands, WorkbenchQueries } from '@workbench/workbenchStore'; import { galleryItems, toGalleryItemKey } from '@features/gallery'; import { getGallerySettings, isGalleryNavigationCurrent, requestGalleryItemReveal } from '@features/gallery/contracts'; -import { - GALLERY_MAX_ROWS, - GALLERY_PAGE_SIZE, - galleryBoardsOptions, - galleryItemNamesOptions, - galleryItemsInfiniteOptions, -} from '@features/gallery/queries'; +import { fetchVerifiedGalleryItemPage, GALLERY_PAGE_SIZE, galleryBoardsOptions } from '@features/gallery/queries'; +import { assertAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { getProjectWidgetValues } from '@workbench/widgetState'; /** Inject workbench dependencies so reveal loads on demand without adding gallery transfer code to editor boot. */ @@ -23,12 +18,15 @@ export interface GalleryRevealContext { } /** - * What the gesture was, at the moment it was made. Both fields are claimed by - * the CALLER and neither may be re-read here: a caller that loads this module - * on demand would otherwise take its ordering, and its project, from whenever - * the chunk happened to land. + * What the gesture was, at the moment it was made. The caller captures the + * account, project, and sequence before lazy loading; the reveal must not take + * any of them from whatever state exists when the chunk lands. */ export interface GalleryRevealTicket { + /** Account identity lifetime at the original gesture, before any lazy import or media fetch. */ + accountScope: AccountScope; + /** Canceled when a later Gallery navigation supersedes this locator. */ + locatorSignal?: AbortSignal; /** The project the press belongs to; its writes may not land in another. */ projectId: string; /** This navigation's place in the global ordering; see `claimGalleryNavigationSequence`. */ @@ -36,46 +34,26 @@ export interface GalleryRevealTicket { } /** - * Extends the infinite window until it covers `pagesNeeded` pages. This must - * NOT be a plain prefetch: the mounted gallery keeps the query fresh, and - * `fetchQuery` returns fresh cache without honoring the `pages` option — the - * reveal has to force the fetch (staleTime 0) or the window never grows. Two - * passes because a concurrent fetch already in flight (a second rapid click) - * absorbs the call without extending; the retry runs after it settles. - */ -const ensureGalleryPagesLoaded = async ( - queryClient: QueryClient, - listingFilter: GalleryItemsFilter, - pagesNeeded: number -): Promise => { - const options = galleryItemsInfiniteOptions(listingFilter, { kind: 'infinite' }); - - for (let attempt = 0; attempt < 2; attempt += 1) { - const data = queryClient.getQueryData<{ pages: unknown[] }>(options.queryKey); - - if ((data?.pages.length ?? 0) >= pagesNeeded) { - return; - } - - await queryClient.fetchInfiniteQuery({ ...options, pages: pagesNeeded, staleTime: 0 }); - } -}; - -/** - * Reveal a freshly resolved item in its board/view with filters cleared and its page loaded; selection drives grid - * scrolling and Preview. Do not raise widgets. Caller-minted gesture tickets fence project changes and later - * selections even across lazy loading. Hydration failures reject without changing selection; position failures - * only lose scrolling. + * Reveal a freshly resolved item in its board/view after its exact filtered-list position and page agree. Do not + * change Gallery state until the page verifies the locator result. Caller-minted gesture tickets fence project + * changes and later selections even across lazy loading. */ export const revealGalleryItem = ( { commands, queries, queryClient }: GalleryRevealContext, ref: GalleryItemRef, - { projectId, sequence }: GalleryRevealTicket + { accountScope, locatorSignal, projectId, sequence }: GalleryRevealTicket ): Promise => { // Fence the network result to the gesture's project before clearing filters or selecting. - const isCurrent = () => isGalleryNavigationCurrent(sequence) && queries.isActiveProject(projectId); + const isCurrent = () => + !accountScope.signal.aborted && isGalleryNavigationCurrent(sequence) && queries.isActiveProject(projectId); + + if (!isCurrent()) { + return Promise.resolve(); + } return galleryItems.resolve(ref).then(async (image) => { + assertAccountScopeCurrent(accountScope); + if (!isCurrent()) { return; } @@ -92,38 +70,27 @@ export const revealGalleryItem = ( searchTerm: '', starred: wantsStarredOnly, }; - // Resolve position for paging, but preserve selection on failure. Check board visibility to avoid using a - // hidden board's page in Uncategorized. - let boardIndex: number | null = null; - - try { - const boardsPromise = queryClient - .fetchQuery( - galleryBoardsOptions({ - includeArchived: settings.showArchivedBoards, - includeDateBoards: settings.showDateBoards, - orderBy: settings.boardOrderBy, - orderDir: settings.boardOrderDir, - }) - ) - // Unknown beats blocked: without the boards list the reveal - // proceeds as if the board were listable. - .catch(() => null); - const names = await queryClient.fetchQuery(galleryItemNamesOptions(listingFilter)); - const boards = await boardsPromise; - const index = names.items.findIndex((item) => item.kind === ref.kind && item.name === ref.name); - const isBoardListable = - image.boardId === 'none' || - boards === null || - boards.length === 0 || - boards.some((board) => board.id === image.boardId); - - boardIndex = index >= 0 && isBoardListable ? index : null; - } catch { - boardIndex = null; - } - - if (!isCurrent()) { + // The board list determines whether the destination can present an archived board. The locator itself uses + // the same filters as the destination's 60-item page and avoids downloading every item name. + const boardsPromise = queryClient + .fetchQuery( + galleryBoardsOptions({ + includeArchived: settings.showArchivedBoards, + includeDateBoards: settings.showDateBoards, + orderBy: settings.boardOrderBy, + orderDir: settings.boardOrderDir, + }) + ) + // Unknown beats blocked: without the boards list the reveal proceeds as if the board were listable. + .catch(() => null); + const [verified, boards] = await Promise.all([ + fetchVerifiedGalleryItemPage(queryClient, listingFilter, ref, accountScope, locatorSignal), + boardsPromise, + ]); + + assertAccountScopeCurrent(accountScope); + + if (!isCurrent() || !verified) { return; } @@ -134,9 +101,17 @@ export const revealGalleryItem = ( // in flight (sort direction); the computed index describes the old // ordering, so the page landing is dropped. if (settingsNow.imageOrderDir !== settings.imageOrderDir) { - boardIndex = null; + return; } + // Do not use a hidden board's offset when board resolution will present Uncategorized instead. + const isBoardListable = + image.boardId === 'none' || + boards === null || + boards.length === 0 || + boards.some((board) => board.id === image.boardId); + const boardIndex = isBoardListable ? verified.index : null; + const currentView: GalleryView = values.galleryView === 'assets' ? 'assets' : 'images'; const hasSearch = (typeof values.searchTerm === 'string' && values.searchTerm !== '') || @@ -167,24 +142,15 @@ export const revealGalleryItem = ( const page = boardIndex !== null ? Math.floor(boardIndex / GALLERY_PAGE_SIZE) : null; if (page !== null && settingsNow.paginationMode === 'paginated') { + // The verified page is cached, and its own total lets the listing open it past a stale retained total. commands.gallery.setPage(page); } - if (boardIndex !== null && page !== null && settingsNow.paginationMode === 'infinite') { - if (boardIndex < GALLERY_MAX_ROWS) { - // Load pages through the item without delaying selection; the grid completes its reveal when the item - // arrives. - void ensureGalleryPagesLoaded(queryClient, listingFilter, page + 1).catch(() => {}); - } else { - // Deeper than the base window can ever load: anchor the - // infinite window at the image's page instead (the mounted - // gallery query fetches it on its own). Any board, search, or - // view change resets the anchor back to the top. - commands.gallery.setPage(page); - } - } - commands.gallery.selectItem(image, projectId, page ?? undefined); - requestGalleryItemReveal(toGalleryItemKey(ref)); + if (boardIndex === null) { + requestGalleryItemReveal(toGalleryItemKey(ref), accountScope.signal); + } else { + requestGalleryItemReveal(toGalleryItemKey(ref), accountScope.signal, boardIndex); + } }); }; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.lazy.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.lazy.test.ts new file mode 100644 index 00000000000..cfb5f0b754b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.lazy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@features/gallery/queries', () => { + throw new Error('The Gallery data module must stay behind the reveal dynamic import.'); +}); + +vi.mock('@tanstack/react-query', () => ({ useQueryClient: vi.fn() })); +vi.mock('@workbench/useOpenWorkbenchWidget', () => ({ useOpenWorkbenchWidget: vi.fn() })); +vi.mock('@workbench/WorkbenchContext', () => ({ + useWorkbenchCommands: vi.fn(), + useWorkbenchQueries: vi.fn(), +})); + +describe('useFindGalleryItem lazy boundary', () => { + it('loads without importing Gallery data queries', async () => { + await expect(import('./useFindGalleryItem')).resolves.toHaveProperty('useFindGalleryItem'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.ts index 8e012d0dadb..0027e0357b1 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useFindGalleryItem.ts @@ -4,6 +4,8 @@ import type { WidgetRegion } from '@workbench/layoutContracts'; import type { Project } from '@workbench/projectContracts'; import { claimGalleryNavigationSequence, isGalleryNavigationCurrent } from '@features/gallery/contracts'; +import { abortGalleryLocatorRequests, createGalleryLocatorRequest } from '@features/gallery/utility'; +import { captureAccountScope } from '@platform/state/accountLifecycle'; import { useQueryClient } from '@tanstack/react-query'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; import { useWorkbenchCommands, useWorkbenchQueries } from '@workbench/WorkbenchContext'; @@ -49,9 +51,14 @@ export const useFindGalleryItem = (): ((ref: GalleryItemRef, options?: FindGalle // Minted here, not inside the import below: both the ordering and the // project fence describe the PRESS, and reading either after the chunk // lands would pin whatever the workspace had become by then. + const sequence = claimGalleryNavigationSequence(); + abortGalleryLocatorRequests(); + const locatorRequest = createGalleryLocatorRequest(); const ticket: GalleryRevealTicket = { + accountScope: captureAccountScope(), + locatorSignal: locatorRequest.signal, projectId: activeProject.id, - sequence: claimGalleryNavigationSequence(), + sequence, }; const galleryRegions = getGalleryRegions(activeProject); @@ -69,7 +76,11 @@ export const useFindGalleryItem = (): ((ref: GalleryItemRef, options?: FindGalle .catch((error: unknown) => { // Report chunk or media failures only for the still-current gesture, since widgets already moved. Keep // the failed gesture's claim so older intents cannot reclaim selection. - if (!isGalleryNavigationCurrent(ticket.sequence) || !queries.isActiveProject(ticket.projectId)) { + if ( + ticket.accountScope.signal.aborted || + !isGalleryNavigationCurrent(ticket.sequence) || + !queries.isActiveProject(ticket.projectId) + ) { return; } @@ -78,7 +89,8 @@ export const useFindGalleryItem = (): ((ref: GalleryItemRef, options?: FindGalle message: error instanceof Error ? error.message : String(error), namespace: 'gallery', }); - }); + }) + .finally(locatorRequest.release); }, [commands, openWorkbenchWidget, queries, queryClient] ); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx index aacaae8f56f..57e088123e2 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx @@ -837,12 +837,36 @@ describe('mixed item mutation outcomes', () => { expect(mocks.galleryRemoveItems).toHaveBeenNthCalledWith(1, ['video:gone.mp4', 'image:locked.png']); expect(mocks.patchGalleryItemCaches.mock.results[0]?.value).toHaveBeenCalledOnce(); - expect(mocks.patchGalleryItemCaches).toHaveBeenNthCalledWith(2, expect.anything(), { kind: 'delete', result }); + // The rollback restored every page's total, so the re-removal lowers them across the listing again. + expect(mocks.patchGalleryItemCaches).toHaveBeenNthCalledWith( + 2, + expect.anything(), + { kind: 'delete', result }, + { totals: 'listing' } + ); expect(mocks.galleryRemoveItems).toHaveBeenNthCalledWith(2, ['video:gone.mp4']); expect(mocks.invalidateGallery).toHaveBeenCalledOnce(); expect(mocks.notificationsAdd.mock.calls.length + mocks.reportError.mock.calls.length).toBe(1); }); + it('re-removes a fully confirmed deletion only from pages still holding it, without lowering totals again', async () => { + const refs = [{ kind: 'image' as const, name: 'gone.png' }]; + const result = { affectedBoardIds: ['board-1'], failed: [], succeeded: refs }; + mocks.itemDelete.mockResolvedValue(result); + + await act(async () => { + await getItemActions().deleteItems(refs); + }); + + expect(mocks.patchGalleryItemCaches.mock.results[0]?.value).not.toHaveBeenCalled(); + expect(mocks.patchGalleryItemCaches).toHaveBeenNthCalledWith( + 2, + expect.anything(), + { kind: 'delete', result }, + { totals: 'holder' } + ); + }); + it('attempts operation-level invalidation only once when invalidation itself rejects', async () => { const ref = { kind: 'video' as const, name: 'clip.mp4' }; mocks.itemSetStarred.mockResolvedValue({ @@ -921,6 +945,39 @@ describe('total transport failure rollback', () => { ); }); + it('restores the selection when the server rejects every item instead of failing the request', async () => { + const refs = [{ kind: 'image' as const, name: 'gone.png' }]; + const selected = { kind: 'image', name: 'gone.png' }; + const before = makeMockProject('project-1', { + selectedImage: selected, + selectedImageName: 'image:gone.png', + selectedImageNames: ['image:gone.png'], + }); + const afterRemoval = makeMockProject('project-1', { + selectedImage: null, + selectedImageName: null, + selectedImageNames: [], + }); + + mocks.getSnapshot.mockReturnValueOnce({ activeProject: before, projects: [before] }).mockReturnValue({ + activeProject: afterRemoval, + projects: [afterRemoval], + }); + // A rejected request is reported as a result whose items all failed. + mocks.itemDelete.mockResolvedValue({ affectedBoardIds: [], failed: refs, succeeded: [] }); + + await act(async () => { + await getItemActions().deleteItems(refs); + }); + + expect(mocks.galleryWidgetsPatchValues).toHaveBeenCalledWith( + 'gallery', + { selectedImage: selected, selectedImageName: 'image:gone.png', selectedImageNames: ['image:gone.png'] }, + 'project-1', + 'system' + ); + }); + it('does not clobber a gallery widget field something else changed before the rollback runs', async () => { const refs = [{ kind: 'image' as const, name: 'gone.png' }]; const before = makeMockProject('project-1', { recentImages: [recentImageFixture] }); @@ -958,10 +1015,12 @@ describe('total transport failure rollback', () => { }); // The confirmed deletion cache patch applied before the callback threw. - expect(mocks.patchGalleryItemCaches).toHaveBeenNthCalledWith(2, expect.anything(), { - kind: 'delete', - result, - }); + expect(mocks.patchGalleryItemCaches).toHaveBeenNthCalledWith( + 2, + expect.anything(), + { kind: 'delete', result }, + { totals: 'holder' } + ); // The `rollbackCaches` closure returned for the *optimistic* patch (call // 1) must never fire once confirmation has begun applying. expect(mocks.patchGalleryItemCaches.mock.results[0]?.value).not.toHaveBeenCalled(); @@ -1119,6 +1178,27 @@ const galleryItem = (kind: GalleryItem['kind'], name: string): GalleryItem => { return kind === 'video' ? { ...base, durationSeconds: 4, kind } : { ...base, kind }; }; +/** Backs the workbench snapshot with a gallery selection that removals and selections change, as the store does. */ +const modelGallerySelectionStore = (initial: GalleryItemKey | null) => { + let selected = initial; + mocks.getSnapshot.mockImplementation(() => { + const project = makeMockProject('project-1', { selectedImageName: selected }); + + return { activeProject: project, projects: [project] }; + }); + + return { + remove: (itemKeys: GalleryItemKey[]) => { + if (selected && itemKeys.includes(selected)) { + selected = null; + } + }, + select: (itemKey: GalleryItemKey) => { + selected = itemKey; + }, + }; +}; + describe('primary successor after confirmed deletion', () => { it('selects the next surviving item in display order — the one that takes the deleted slot', async () => { // Selection also keeps deletion out of the leading starred block; core/selection.test.ts covers that rule. @@ -1145,6 +1225,111 @@ describe('primary successor after confirmed deletion', () => { expect(mocks.gallerySelectItem).toHaveBeenCalledWith(after, 'project-1'); }); + it('selects the successor once the optimistic removal has cleared the host selection', async () => { + const primary = galleryItem('image', 'primary.png'); + const after = galleryItem('image', 'after.png'); + const refs = [primary, after].map(({ kind, name }) => ({ kind, name })); + currentItemActionContext = { + filterIdentity: 'filter-a', + items: [primary, after], + loadOrderedRefs: () => Promise.resolve(refs), + selectedItemKey: 'image:primary.png', + }; + const store = modelGallerySelectionStore('image:primary.png'); + mocks.galleryRemoveItems.mockImplementationOnce((itemKeys: GalleryItemKey[]) => { + store.remove(itemKeys); + currentItemActionContext = { ...currentItemActionContext!, items: [after], selectedItemKey: null }; + }); + mocks.itemDelete.mockResolvedValue({ + affectedBoardIds: ['board-1'], + failed: [], + succeeded: [{ kind: 'image', name: primary.name }], + }); + + await act(async () => { + await getItemActions().deleteItems([{ kind: 'image', name: primary.name }]); + }); + + expect(mocks.gallerySelectItem).toHaveBeenCalledWith(after, 'project-1'); + }); + + it('selects the successor beside a rejected item when the host has re-rendered without the primary', async () => { + const primary = galleryItem('image', 'primary.png'); + const rejected = galleryItem('image', 'rejected.png'); + const after = galleryItem('image', 'after.png'); + const refs = [primary, rejected, after].map(({ kind, name }) => ({ kind, name })); + currentItemActionContext = { + filterIdentity: 'filter-a', + items: [primary, rejected, after], + loadOrderedRefs: () => Promise.resolve(refs), + selectedItemKey: 'image:primary.png', + }; + const store = modelGallerySelectionStore('image:primary.png'); + mocks.galleryRemoveItems.mockImplementationOnce((itemKeys: GalleryItemKey[]) => { + store.remove(itemKeys); + currentItemActionContext = { ...currentItemActionContext!, items: [after], selectedItemKey: null }; + }); + // Restoring the rejected item's removal writes the selection back to the store; the host has not re-rendered. + mocks.galleryWidgetsPatchValues.mockImplementation((_widgetId: string, values: Record) => { + if (typeof values.selectedImageName === 'string') { + store.select(values.selectedImageName as GalleryItemKey); + } + }); + mocks.itemDelete.mockResolvedValue({ + affectedBoardIds: ['board-1'], + failed: [{ kind: 'image', name: rejected.name }], + succeeded: [{ kind: 'image', name: primary.name }], + }); + + await act(async () => { + await getItemActions().deleteItems([ + { kind: 'image', name: primary.name }, + { kind: 'image', name: rejected.name }, + ]); + }); + + expect(mocks.gallerySetItemMultiSelection).toHaveBeenCalledWith( + ['image:rejected.png', 'image:after.png'], + after, + 'project-1' + ); + }); + + it('keeps an item selected during the deletion even when the host has not loaded it', async () => { + const primary = galleryItem('image', 'primary.png'); + const after = galleryItem('image', 'after.png'); + const refs = [primary, after].map(({ kind, name }) => ({ kind, name })); + currentItemActionContext = { + filterIdentity: 'filter-a', + items: [primary, after], + loadOrderedRefs: () => Promise.resolve(refs), + selectedItemKey: 'image:primary.png', + }; + const store = modelGallerySelectionStore('image:primary.png'); + mocks.galleryRemoveItems.mockImplementationOnce((itemKeys: GalleryItemKey[]) => { + store.remove(itemKeys); + currentItemActionContext = { ...currentItemActionContext!, items: [after], selectedItemKey: null }; + }); + // While the request is in flight the user selects an item on a page the host has not loaded, so the host + // still reports no selection. + mocks.itemDelete.mockImplementation(() => { + store.select('image:far-away.png'); + + return Promise.resolve({ + affectedBoardIds: ['board-1'], + failed: [], + succeeded: [{ kind: 'image', name: primary.name }], + }); + }); + + await act(async () => { + await getItemActions().deleteItems([{ kind: 'image', name: primary.name }]); + }); + + expect(mocks.gallerySelectItem).not.toHaveBeenCalled(); + expect(mocks.gallerySetItemMultiSelection).not.toHaveBeenCalled(); + }); + it('resolves an unloaded successor by qualified ref', async () => { const primary = galleryItem('image', 'primary.png'); const before = galleryItem('image', 'before.png'); @@ -1326,7 +1511,8 @@ describe('primary successor after confirmed deletion', () => { ['image:failed.png', 'image:successor.png'], successor, 'project-1', - 30 + 30, + true ); }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 354ed275570..7fec9fa100f 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -21,7 +21,11 @@ import { galleryVideos, type GalleryVideoItem, } from '@features/gallery'; -import { getGalleryBoardLabel, getGalleryDeletionSuccessor } from '@features/gallery/contracts'; +import { + getGalleryBoardLabel, + getGalleryDeletionSuccessor, + getPersistedSelectedGalleryItemKeys, +} from '@features/gallery/contracts'; import { getGalleryItemBoardIdsFromCaches, getGalleryItemStarredFromCaches, @@ -256,21 +260,25 @@ export const useImageActions = ({ return board ? getGalleryBoardLabel(board, t) : t('widgets.gallery.uncategorized'); }; - const getLatestGenerateValues = () => { + const getLatestWidgetValues = (widgetId: 'gallery' | 'generate' | 'video') => { const snapshot = queries.getSnapshot(); const project = projectId ? snapshot.projects.find((candidate) => candidate.id === projectId) : snapshot.activeProject; - return project ? getProjectWidgetValues(project, 'generate') : {}; + return project ? getProjectWidgetValues(project, widgetId) : {}; }; - const getLatestVideoValues = () => { - const snapshot = queries.getSnapshot(); - const project = projectId - ? snapshot.projects.find((candidate) => candidate.id === projectId) - : snapshot.activeProject; - - return project ? getProjectWidgetValues(project, 'video') : {}; + const getLatestGenerateValues = () => getLatestWidgetValues('generate'); + const getLatestVideoValues = () => getLatestWidgetValues('video'); + // The persisted selection, primary and members, as one comparable key: a host's action context only sees + // loaded items, so it cannot tell a cleared selection from one moved to an item it has not loaded. + const getPersistedGallerySelectionKey = (): string => { + const values = getLatestWidgetValues('gallery'); + + return JSON.stringify([ + typeof values.selectedImageName === 'string' ? values.selectedImageName : null, + getPersistedSelectedGalleryItemKeys(values), + ]); }; // Snapshot deletion-sensitive widget values across all projects; cache rollback cannot restore them. Diff // before/after values for conflict-safe restoration. @@ -373,8 +381,8 @@ export const useImageActions = ({ reportMutationOutcome(action, requested.length, result, boardId); }; const deleteItemsConfirmed = (items: GalleryItemRef[]): Promise => { - // Capture successor context before optimistic removal. Partial failures reconcile via invalidation; total - // failures restore widget snapshots directly. + // Capture successor context before optimistic removal. Any rejected items restore the widget snapshot; the + // confirmed removals then apply again. const deletionContext = getItemActionContext?.() ?? null; let orderedRefs: GalleryItemRef[] | null = null; const isDeletionContextCurrent = (): boolean => { @@ -384,10 +392,14 @@ export const useImageActions = ({ const current = getItemActionContext(); + // The optimistic removal below clears the deleted primary from the host's selection, so the selection is + // still this deletion's while the store holds what that removal (or restoring rejected items) left; + // anything selected since, loaded by the host or not, makes the successor stale. return Boolean( current && current.filterIdentity === deletionContext.filterIdentity && - current.selectedItemKey === deletionContext.selectedItemKey + (current.selectedItemKey === deletionContext.selectedItemKey || + getPersistedGallerySelectionKey() === selectionAfterRemoval) ); }; const rollbackCaches = patchGalleryItemCaches(queryClient, { @@ -408,12 +420,24 @@ export const useImageActions = ({ // Once backend-confirmed deletion starts applying, later callback failures must not restore deleted items. let confirmedApplied = false; const galleryWidgetSnapshot = applyGalleryItemRemoval(items.map(toGalleryItemKey)); + let selectionAfterRemoval = getPersistedGallerySelectionKey(); return runItemMutation({ action: 'delete', applyConfirmed: async (result, signal) => { + // A rejected request reports its items as failed rather than throwing. Those items remain, so restore what + // the optimistic removal cleared; confirmed removals are applied again below. if (result.failed.length > 0) { + // Restoring changes the persisted selection the successor fence compares against: adopt it only when + // the selection was still this deletion's, so a choice made meanwhile still wins. + const wasCurrent = isDeletionContextCurrent(); + rollbackCachesOnce(); + restoreGalleryItemRemoval(galleryWidgetSnapshot); + + if (wasCurrent) { + selectionAfterRemoval = getPersistedGallerySelectionKey(); + } } if (result.succeeded.length === 0) { @@ -450,7 +474,13 @@ export const useImageActions = ({ } confirmedApplied = true; - patchGalleryItemCaches(queryClient, { kind: 'delete', result }); + // Without a rollback the optimistic patch already lowered every page's total; only pages refetched since + // still count the deleted items. + patchGalleryItemCaches( + queryClient, + { kind: 'delete', result }, + { totals: cachesRolledBack ? 'listing' : 'holder' } + ); gallery.removeItems(result.succeeded.map(toGalleryItemKey)); if (successor) { const failedKeys = new Set(result.failed.map(toGalleryItemKey)); @@ -468,7 +498,7 @@ export const useImageActions = ({ if (selectionPage === undefined) { gallery.setItemMultiSelection(itemKeys, successor, projectId); } else { - gallery.setItemMultiSelection(itemKeys, successor, projectId, selectionPage); + gallery.setItemMultiSelection(itemKeys, successor, projectId, selectionPage, true); } } else if (selectionPage === undefined) { gallery.selectItem(successor, projectId); diff --git a/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPaletteDialog.tsx b/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPaletteDialog.tsx index bcaae355ed1..f59956c49d2 100644 --- a/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPaletteDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPaletteDialog.tsx @@ -188,7 +188,7 @@ const WorkbenchCommandPaletteDialog = ({ const imageEntryDeps = { openPreviewWidget: () => openWidget('preview'), revealImage: (image: GalleryImage) => findGalleryItem({ kind: 'image', name: image.imageName }), - selectImage: (image: GalleryImage) => gallery.selectImage(image), + selectImage: (image: GalleryImage) => gallery.selectImageInItsBoard(image), locale: i18n.resolvedLanguage, t, }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx index b07bb8e1f0c..2c700924a24 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.browser.test.tsx @@ -1,16 +1,14 @@ import type * as GalleryContracts from '@features/gallery/contracts'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; import { act, useEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ activeProjectId: 'project-1', - /** Simulated page count of the cached infinite window; null = no cache. */ - cachedPageCount: null as number | null, fetchBoards: vi.fn(), - fetchInfiniteQuery: vi.fn(), - fetchNames: vi.fn(), + fetchVerifiedPage: vi.fn(), galleryValues: {} as Record, patchValues: vi.fn(), registerImageCluster: vi.fn(), @@ -18,6 +16,7 @@ const mocks = vi.hoisted(() => ({ resolve: vi.fn(), selectBoard: vi.fn(), selectItem: vi.fn(), + locatorControllers: [] as AbortController[], setPage: vi.fn(), settings: { imageOrderDir: 'DESC', paginationMode: 'paginated' } as Record, setView: vi.fn(), @@ -37,26 +36,27 @@ vi.mock('@features/gallery/contracts', async (importOriginal) => ({ requestGalleryItemReveal: mocks.requestReveal, })); +vi.mock('@features/gallery/utility', () => ({ + abortGalleryLocatorRequests: () => { + mocks.locatorControllers.forEach((controller) => controller.abort()); + }, + createGalleryLocatorRequest: () => { + const controller = new AbortController(); + mocks.locatorControllers.push(controller); + + return { signal: controller.signal, release: vi.fn() }; + }, +})); + vi.mock('@features/gallery/queries', () => ({ - GALLERY_MAX_ROWS: 600, GALLERY_PAGE_SIZE: 60, galleryBoardsOptions: (query: unknown) => ({ kind: 'boards', query, queryKey: ['boards', query] }), - galleryItemNamesOptions: (filter: unknown) => ({ filter, kind: 'names', queryKey: ['names', filter] }), - galleryItemsInfiniteOptions: (filter: unknown, window: unknown) => ({ - filter, - kind: 'items', - queryKey: ['items', filter, window], - window, - }), + fetchVerifiedGalleryItemPage: (...args: unknown[]) => mocks.fetchVerifiedPage(...args), })); vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ - fetchInfiniteQuery: (options: { pages: number }) => mocks.fetchInfiniteQuery(options), - fetchQuery: (options: { kind: string }) => - options.kind === 'boards' ? mocks.fetchBoards(options) : mocks.fetchNames(options), - getQueryData: () => - mocks.cachedPageCount === null ? undefined : { pages: Array.from({ length: mocks.cachedPageCount }) }, + fetchQuery: (options: { kind: string }) => mocks.fetchBoards(options), }), })); @@ -149,29 +149,22 @@ const deferred = () => { return { promise, resolve }; }; -/** A names response placing `imageName` at `index` in its board's ordering. */ -const namesWithImageAt = (imageName: string, index: number) => ({ - items: Array.from({ length: index + 1 }, (_, position) => ({ - kind: 'image', - name: position === index ? imageName : `other-${String(position)}.png`, - })), - total: index + 1, +const verifiedAt = (index: number, total = index + 1) => ({ + index, + offset: Math.floor(index / 60) * 60, + page: { items: [], total }, + total, }); beforeEach(() => { mocks.activeProjectId = 'project-1'; - mocks.cachedPageCount = null; mocks.galleryValues = {}; mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'paginated' }; + mocks.locatorControllers.length = 0; // Empty boards read as "still loading" — the reveal gives the board the // benefit of the doubt, matching the gallery's own fallback rules. mocks.fetchBoards.mockResolvedValue([]); - mocks.fetchInfiniteQuery.mockImplementation((options: { pages: number }) => { - mocks.cachedPageCount = options.pages; - - return Promise.resolve(); - }); - mocks.fetchNames.mockResolvedValue({ items: [], total: 0 }); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(0)); mocks.registerImageCluster.mockReturnValue('cluster-key-1'); }); @@ -180,8 +173,7 @@ afterEach(async () => { await unmount(); } mocks.fetchBoards.mockReset(); - mocks.fetchInfiniteQuery.mockReset(); - mocks.fetchNames.mockReset(); + mocks.fetchVerifiedPage.mockReset(); mocks.patchValues.mockReset(); mocks.registerImageCluster.mockReset(); mocks.requestReveal.mockReset(); @@ -209,19 +201,13 @@ describe('useMapSelection', () => { }); // The reveal channel is what scrolls the grid; the selection alone must // not (auto-selected generation results would yank the scroll). - expect(mocks.requestReveal).toHaveBeenCalledWith('image:a.png'); + expect(mocks.requestReveal).toHaveBeenCalledWith('image:a.png', expect.any(AbortSignal), 0); }); it('reveals a clicked video through its own namespace', async () => { // Resolve videos through their own endpoint and item keys so gallery reveal matches. mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'video', name: 'clip.mp4' }); - mocks.fetchNames.mockResolvedValue({ - items: [ - { kind: 'image', name: 'a.png' }, - { kind: 'video', name: 'clip.mp4' }, - ], - total_count: 2, - }); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(1, 2)); await mount(); await flush(() => handle.click?.({ kind: 'video', name: 'clip.mp4' })); @@ -233,7 +219,7 @@ describe('useMapSelection', () => { kind: 'video', name: 'clip.mp4', }); - expect(mocks.requestReveal).toHaveBeenCalledWith('video:clip.mp4'); + expect(mocks.requestReveal).toHaveBeenCalledWith('video:clip.mp4', expect.any(AbortSignal), 1); }); it('finds a video at its own position in a mixed listing', async () => { @@ -242,21 +228,14 @@ describe('useMapSelection', () => { // the image's page and the clip is nowhere on screen. mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'paginated' }; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'video', name: 'shared' }); - mocks.fetchNames.mockResolvedValue({ - items: [ - { kind: 'image', name: 'shared' }, - ...Array.from({ length: 119 }, (_, index) => ({ kind: 'image', name: `img-${String(index)}.png` })), - { kind: 'video', name: 'shared' }, - ], - total_count: 121, - }); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(120, 121)); await mount(); await flush(() => handle.click?.({ kind: 'video', name: 'shared' })); // Index 120 of a 60-per-page listing is page 2; the image's index 0 is page 0. expect(mocks.setPage).toHaveBeenCalledWith(2); - expect(mocks.requestReveal).toHaveBeenCalledWith('video:shared'); + expect(mocks.requestReveal).toHaveBeenCalledWith('video:shared', expect.any(AbortSignal), 120); }); it("selects the image's board before the image itself", async () => { @@ -278,7 +257,7 @@ describe('useMapSelection', () => { it('lands the gallery on the page holding the image in paginated mode', async () => { mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'paginated' }; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'deep.png' }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('deep.png', 130)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(130, 200)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); @@ -293,12 +272,12 @@ describe('useMapSelection', () => { it("resolves the image's position against the listing the reveal lands on", async () => { mocks.settings = { imageOrderDir: 'ASC', paginationMode: 'paginated' }; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'a.png' }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('a.png', 0)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(0)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'a.png' })); - expect(mocks.fetchNames.mock.calls[0]?.[0].filter).toEqual({ + expect(mocks.fetchVerifiedPage.mock.calls[0]?.[1]).toEqual({ boardId: 'board-a', galleryView: 'images', orderDir: 'ASC', @@ -307,69 +286,63 @@ describe('useMapSelection', () => { }); }); - it('force-fetches the pages down to the image in infinite mode', async () => { - // A plain prefetch is not enough: the mounted gallery keeps the query - // fresh, and a fresh cache short-circuits the fetch WITHOUT honoring - // the `pages` option — the window would never grow. + it('requests the resolved sparse slot directly in infinite mode', async () => { mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'infinite' }; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'deep.png' }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('deep.png', 130)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(130, 200)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); expect(mocks.setPage).not.toHaveBeenCalled(); - expect(mocks.fetchInfiniteQuery).toHaveBeenCalledTimes(1); - expect(mocks.fetchInfiniteQuery.mock.calls[0]?.[0]).toMatchObject({ pages: 3, staleTime: 0 }); + expect(mocks.requestReveal).toHaveBeenCalledWith('image:deep.png', expect.any(AbortSignal), 130); expect(mocks.selectItem.mock.calls[0]?.[2]).toBe(2); }); - it('skips the fetch when the window already covers the image', async () => { + it('does not require earlier pages to reveal a loaded deep target', async () => { mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'infinite' }; - mocks.cachedPageCount = 5; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'deep.png' }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('deep.png', 130)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(130, 200)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); - expect(mocks.fetchInfiniteQuery).not.toHaveBeenCalled(); + expect(mocks.fetchVerifiedPage).toHaveBeenCalledOnce(); expect(mocks.selectItem).toHaveBeenCalledTimes(1); }); - it('anchors the infinite window at the page of an image past the base reach', async () => { - // Anchor deep reveals at their page when loading from the base would exceed GALLERY_MAX_ROWS. + it('requests an absolute slot beyond the former infinite-window cap', async () => { mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'infinite' }; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'deep.png' }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('deep.png', 700)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(700, 800)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); - expect(mocks.fetchInfiniteQuery).not.toHaveBeenCalled(); - expect(mocks.setPage).toHaveBeenCalledWith(11); + expect(mocks.setPage).not.toHaveBeenCalled(); expect(mocks.selectItem).toHaveBeenCalledTimes(1); expect(mocks.selectItem.mock.calls[0]?.[2]).toBe(11); + expect(mocks.requestReveal).toHaveBeenCalledWith('image:deep.png', expect.any(AbortSignal), 700); }); it('drops the page landing when the ordering settings changed mid-lookup', async () => { - // The computed index describes the ordering the name list was fetched - // under; landing on that page under a different ordering would show an - // unrelated screen of images. - const names = deferred>(); + // The verified position describes the ordering it was fetched under; + // changing sort before it settles must leave the Gallery untouched. + const location = deferred>(); mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'paginated' }; mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'deep.png' }); - mocks.fetchNames.mockReturnValue(names.promise); + mocks.fetchVerifiedPage.mockReturnValue(location.promise); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); mocks.settings = { imageOrderDir: 'ASC', paginationMode: 'paginated' }; - await flush(() => names.resolve(namesWithImageAt('deep.png', 130))); + await flush(() => location.resolve(verifiedAt(130, 200))); expect(mocks.setPage).not.toHaveBeenCalled(); - expect(mocks.selectItem).toHaveBeenCalledTimes(1); - expect(mocks.selectItem.mock.calls[0]?.[2]).toBeUndefined(); + expect(mocks.selectBoard).not.toHaveBeenCalled(); + expect(mocks.selectItem).not.toHaveBeenCalled(); + expect(mocks.requestReveal).not.toHaveBeenCalled(); }); it('drops the page landing when the board is not listable in the gallery', async () => { @@ -382,7 +355,7 @@ describe('useMapSelection', () => { kind: 'image', name: 'deep.png', }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('deep.png', 130)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(130, 200)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); @@ -396,7 +369,7 @@ describe('useMapSelection', () => { mocks.settings = { imageOrderDir: 'DESC', paginationMode: 'paginated' }; mocks.fetchBoards.mockRejectedValue(new Error('boards endpoint down')); mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'deep.png' }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('deep.png', 130)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(130, 200)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'deep.png' })); @@ -429,12 +402,12 @@ describe('useMapSelection', () => { name: 'a.png', starred: true, }); - mocks.fetchNames.mockResolvedValue(namesWithImageAt('a.png', 0)); + mocks.fetchVerifiedPage.mockResolvedValue(verifiedAt(0)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'a.png' })); - expect(mocks.fetchNames.mock.calls[0]?.[0].filter).toMatchObject({ boardId: 'board-a', starred: true }); + expect(mocks.fetchVerifiedPage.mock.calls[0]?.[1]).toMatchObject({ boardId: 'board-a', starred: true }); expect(mocks.patchValues).toHaveBeenCalledWith('gallery', { searchTerm: '', semanticImageQuery: null, @@ -487,18 +460,17 @@ describe('useMapSelection', () => { expect(mocks.setView).not.toHaveBeenCalled(); }); - it('still selects when the position lookup fails', async () => { + it('leaves Gallery state untouched when a verified position is unavailable', async () => { mocks.resolve.mockResolvedValue({ boardId: 'board-a', category: 'general', kind: 'image', name: 'a.png' }); - mocks.fetchNames.mockRejectedValue(new Error('names endpoint down')); + mocks.fetchVerifiedPage.mockResolvedValue(null); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'a.png' })); expect(mocks.setPage).not.toHaveBeenCalled(); - expect(mocks.selectBoard).toHaveBeenCalledWith('board-a'); - expect(mocks.selectItem).toHaveBeenCalledTimes(1); - expect(mocks.selectItem.mock.calls[0]?.[2]).toBeUndefined(); - expect(mocks.requestReveal).toHaveBeenCalledWith('image:a.png'); + expect(mocks.selectBoard).not.toHaveBeenCalled(); + expect(mocks.selectItem).not.toHaveBeenCalled(); + expect(mocks.requestReveal).not.toHaveBeenCalled(); }); it('does not touch the board for a click that never resolves an image', async () => { @@ -526,7 +498,7 @@ describe('useMapSelection', () => { expect(mocks.resolve).toHaveBeenCalledWith({ kind: 'video', name: 'clip.mp4' }); expect(mocks.registerImageCluster).toHaveBeenCalledWith(['video:clip.mp4', 'image:a.png'], 'beaches'); - expect(mocks.requestReveal).toHaveBeenCalledWith('video:clip.mp4'); + expect(mocks.requestReveal).toHaveBeenCalledWith('video:clip.mp4', expect.any(AbortSignal)); }); it('shows the cluster as a gallery filter with the clicked image selected and revealed', async () => { @@ -558,7 +530,7 @@ describe('useMapSelection', () => { // Re-clicking the same cluster point after scrolling away must return // the grid to the top; the reveal channel carries that even when the // selection is unchanged. - expect(mocks.requestReveal).toHaveBeenCalledWith('image:a.png'); + expect(mocks.requestReveal).toHaveBeenCalledWith('image:a.png', expect.any(AbortSignal)); }); it("selects the primary image's board before the cluster filter", async () => { @@ -591,6 +563,23 @@ describe('useMapSelection', () => { expect(mocks.selectItem).not.toHaveBeenCalled(); expect(mocks.requestReveal).not.toHaveBeenCalled(); }); + + it('retires a cluster click still hydrating when its account epoch changes', async () => { + accountLifecycle.activate('gallery-cluster-test'); + const pending = deferred<{ boardId: string; category: string; kind: string; name: string }>(); + mocks.resolve.mockReturnValueOnce(pending.promise); + await mount(); + + await flush(() => handle.clickCluster?.({ kind: 'image', name: 'a.png' }, ['image:a.png'], 'nearby')); + accountLifecycle.activate('gallery-cluster-next-account'); + await flush(() => pending.resolve({ boardId: 'board-a', category: 'general', kind: 'image', name: 'a.png' })); + + expect(mocks.registerImageCluster).not.toHaveBeenCalled(); + expect(mocks.patchValues).not.toHaveBeenCalled(); + expect(mocks.selectBoard).not.toHaveBeenCalled(); + expect(mocks.selectItem).not.toHaveBeenCalled(); + expect(mocks.requestReveal).not.toHaveBeenCalled(); + }); }); it('shares one sequence guard across both modes, so the newer click wins', async () => { @@ -677,21 +666,20 @@ describe('useMapSelection', () => { expect(mocks.selectItem.mock.calls.map((call) => call[0].name)).toEqual(['fast.png']); }); - it('ignores a click whose position lookup lands after a newer click', async () => { - // The guard must hold across BOTH async hops: the hydrate and the - // name-list fetch. A click whose names arrive late must not move the - // gallery after a newer click has already landed it elsewhere. - const slowNames = deferred>(); + it('ignores a click whose verified page lands after a newer click', async () => { + const slowLocation = deferred>(); mocks.resolve .mockResolvedValueOnce({ boardId: 'board-a', category: 'general', kind: 'image', name: 'slow.png' }) .mockResolvedValueOnce({ boardId: 'board-b', category: 'general', kind: 'image', name: 'fast.png' }); - mocks.fetchNames.mockReturnValueOnce(slowNames.promise).mockResolvedValueOnce(namesWithImageAt('fast.png', 0)); + mocks.fetchVerifiedPage.mockReturnValueOnce(slowLocation.promise).mockResolvedValueOnce(verifiedAt(0)); await mount(); await flush(() => handle.click?.({ kind: 'image', name: 'slow.png' })); + const slowSignal = mocks.fetchVerifiedPage.mock.calls[0]?.[4] as AbortSignal; await flush(() => handle.click?.({ kind: 'image', name: 'fast.png' })); - await flush(() => slowNames.resolve(namesWithImageAt('slow.png', 0))); + expect(slowSignal.aborted).toBe(true); + await flush(() => slowLocation.resolve(verifiedAt(1, 2))); expect(mocks.selectBoard.mock.calls).toEqual([['board-b']]); expect(mocks.selectItem.mock.calls.map((call) => call[0].name)).toEqual(['fast.png']); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts index cdd6ae4e5e6..6829c96f789 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/useSelectMapImage.ts @@ -8,6 +8,8 @@ import { registerImageCluster, requestGalleryItemReveal, } from '@features/gallery/contracts'; +import { abortGalleryLocatorRequests, createGalleryLocatorRequest } from '@features/gallery/utility'; +import { captureAccountScope } from '@platform/state/accountLifecycle'; import { useQueryClient } from '@tanstack/react-query'; import { revealGalleryItem } from '@workbench/image-actions/revealGalleryItem'; import { getProjectWidgetValues } from '@workbench/widgetState'; @@ -31,25 +33,42 @@ export const useMapSelection = (): MapSelectionActions => { const queryClient = useQueryClient(); const selectItem = useCallback( (ref: GalleryItemRef) => { - const ticket = { projectId: queries.getSnapshot().activeProject.id, sequence: claimGalleryNavigationSequence() }; + const sequence = claimGalleryNavigationSequence(); + abortGalleryLocatorRequests(); + const locatorRequest = createGalleryLocatorRequest(); + const ticket = { + accountScope: captureAccountScope(), + locatorSignal: locatorRequest.signal, + projectId: queries.getSnapshot().activeProject.id, + sequence, + }; - void revealGalleryItem({ commands, queries, queryClient }, ref, ticket).catch(() => { - // A click on a just-deleted point, or a blip mid-backend-restart, simply - // leaves the selection unchanged. The map sits beside the grid and has - // moved nothing, so there is nothing to explain. - }); + void revealGalleryItem({ commands, queries, queryClient }, ref, ticket) + .catch(() => { + // A click on a just-deleted point, or a blip mid-backend-restart, simply + // leaves the selection unchanged. The map sits beside the grid and has + // moved nothing, so there is nothing to explain. + }) + .finally(locatorRequest.release); }, [commands, queries, queryClient] ); const selectCluster = useCallback( (primaryItem: GalleryItemRef, itemKeys: GalleryItemKey[], label: string) => { + const accountScope = captureAccountScope(); + const projectId = queries.getSnapshot().activeProject.id; const sequence = claimGalleryNavigationSequence(); + abortGalleryLocatorRequests(); galleryItems .resolve(primaryItem) .then((image) => { - if (!isGalleryNavigationCurrent(sequence)) { + if ( + accountScope.signal.aborted || + !isGalleryNavigationCurrent(sequence) || + !queries.isActiveProject(projectId) + ) { return; } @@ -70,13 +89,13 @@ export const useMapSelection = (): MapSelectionActions => { // Select and reveal the proximity list's first item even if already selected, restoring scroll after a // repeated click. commands.gallery.selectItem(image); - requestGalleryItemReveal(toGalleryItemKey(primaryItem)); + requestGalleryItemReveal(toGalleryItemKey(primaryItem), accountScope.signal); }) .catch(() => { // Selection is simply left unchanged on hydrate failure. }); }, - [commands] + [commands, queries] ); return useMemo(() => ({ selectCluster, selectItem }), [selectCluster, selectItem]); @@ -102,6 +121,7 @@ export const useClearClusterSelection = (): (() => void) => { } claimGalleryNavigationSequence(); + abortGalleryLocatorRequests(); widgets.patchValues('gallery', { galleryPage: 0, searchTerm: '', diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx index 19fa8cd552e..81799c53bbf 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx @@ -28,6 +28,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { userEvent } from 'vitest/browser'; import { LivePreviewFollowProvider, useLivePreviewFollow } from './livePreviewFollow'; +import { usePreviewHeaderContext, type PreviewItemPosition } from './previewHeaderStore'; const queueItem: QueueItem = { backendItemIds: [1], @@ -132,8 +133,23 @@ const mocks = vi.hoisted(() => { galleryStripFetches: [] as Array<{ boardId: string; starred?: boolean }>, galleryStripItems: [] as Array, galleryItemPageOffsets: [] as number[], - galleryItemWindowOffsets: [] as number[], + galleryItemPageQueryKeys: [] as Array<{ offset: number; queryKey: readonly unknown[] }>, + galleryPageFetchSignals: [] as AbortSignal[], + deferredPageFetches: new Map< + number, + { + ignoreAbort?: boolean; + promise: Promise; + reject?: (error: Error) => void; + resolve: (page: GalleryItemsPage) => void; + } + >(), + verifiedGalleryPage: null as null | { index: number; offset: number; page: GalleryItemsPage; total: number }, + verifiedGalleryPageFetches: [] as Array<{ ref: { kind: 'image' | 'video'; name: string }; signal: AbortSignal }>, galleryItemPages: [] as GalleryItemsPage[], + galleryItemNames: [] as Array<{ kind: 'image' | 'video'; name: string }>, + galleryItemNamesGate: null as Promise | null, + galleryItemNamesOptionCalls: 0, imageActionOptions: null as null | { getItemActionContext?: () => { getItemSelectionPage?: (item: GalleryImageItem | GalleryVideoItem) => number; @@ -209,6 +225,7 @@ vi.mock('@workbench/hotkeys/firstPartyCommands', () => ({ useRegisterFirstPartyC vi.mock('@features/gallery/queries', () => ({ GALLERY_MAX_ROWS: 600, GALLERY_PAGE_SIZE: 60, + isDateBoardId: (boardId: string) => boardId.startsWith('by_date:'), flattenGalleryItemsData: (data: InfiniteData | undefined) => data?.pages.flatMap((page) => page.items) ?? [], galleryBoardsOptions: () => ({ queryFn: () => [], queryKey: ['test-boards'], staleTime: Infinity }), @@ -228,7 +245,9 @@ vi.mock('@features/gallery/queries', () => ({ ) => { mocks.galleryItemFilters.push(query); const pages = mocks.galleryItemPages.map((page) => { - const items = page.items.filter((item) => item.boardId === query.boardId); + const items = page.items.filter( + (item) => item.boardId === query.boardId && (query.starred === undefined || item.starred === query.starred) + ); return { ...page, @@ -238,8 +257,6 @@ vi.mock('@features/gallery/queries', () => ({ const initialOffset = window.offset ?? 0; const initialPage = pages[initialOffset / 60] ?? { items: [], total: 0 }; - mocks.galleryItemWindowOffsets.push(initialOffset); - return { getNextPageParam: (_lastPage: GalleryItemsPage, _allPages: GalleryItemsPage[], lastPageParam: number) => pages[lastPageParam / 60 + 1] ? lastPageParam + 60 : undefined, @@ -258,6 +275,119 @@ vi.mock('@features/gallery/queries', () => ({ staleTime: Infinity, }; }, + galleryItemsPageOptions: ( + query: { boardId: string; orderDir?: 'ASC' | 'DESC'; starred?: boolean }, + offset: number + ) => { + mocks.galleryItemFilters.push(query); + const storedPage = mocks.galleryItemPages[offset / 60]; + const items = + storedPage?.items.filter( + (item) => item.boardId === query.boardId && (query.starred === undefined || item.starred === query.starred) + ) ?? []; + const orderedItems = query.orderDir === 'ASC' ? [...items].reverse() : items; + + const queryKey = ['test-items-page', query, offset] as const; + mocks.galleryItemPageQueryKeys.push({ offset, queryKey }); + + return { + queryKey, + queryFn: () => { + mocks.galleryItemPageOffsets.push(offset); + const deferred = mocks.deferredPageFetches.get(offset); + + if (deferred) { + return deferred.promise; + } + + return Promise.resolve({ + ...storedPage, + items: orderedItems, + offset, + total: Math.max(storedPage?.total ?? 0, offset + orderedItems.length), + }); + }, + staleTime: Infinity, + }; + }, + fetchGalleryItemsPage: ( + queryClient: QueryClient, + query: { boardId: string; orderDir?: 'ASC' | 'DESC'; starred?: boolean }, + offset: number, + { signal, staleTime }: { signal?: AbortSignal; staleTime?: number } + ) => { + if (signal) { + mocks.galleryPageFetchSignals.push(signal); + } + const storedPage = mocks.galleryItemPages[offset / 60]; + const items = + storedPage?.items.filter( + (item) => item.boardId === query.boardId && (query.starred === undefined || item.starred === query.starred) + ) ?? []; + const orderedItems = query.orderDir === 'ASC' ? [...items].reverse() : items; + + return queryClient.fetchQuery({ + queryKey: ['test-items-page', query, offset], + queryFn: () => { + mocks.galleryItemPageOffsets.push(offset); + const deferred = mocks.deferredPageFetches.get(offset); + if (deferred && signal) { + if (deferred.ignoreAbort) { + return deferred.promise; + } + + return Promise.race([ + deferred.promise, + new Promise((_resolve, reject) => { + const abort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } + }), + ]); + } + return Promise.resolve({ + ...storedPage, + items: orderedItems, + offset, + total: Math.max(storedPage?.total ?? 0, offset + orderedItems.length), + }); + }, + staleTime: staleTime ?? 60_000, + }); + }, + fetchVerifiedGalleryItemPage: ( + _queryClient: QueryClient, + _query: { boardId: string; orderDir?: 'ASC' | 'DESC'; starred?: boolean }, + ref: { kind: 'image' | 'video'; name: string }, + _owner: unknown, + signal: AbortSignal + ) => { + mocks.verifiedGalleryPageFetches.push({ ref, signal }); + const result = mocks.verifiedGalleryPage; + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } else { + resolve(result); + } + }); + }, + galleryItemNamesOptions: (query: { boardId: string; starred?: boolean }) => { + mocks.galleryItemNamesOptionCalls++; + + return { + queryKey: ['test-item-names', query], + queryFn: async () => { + await mocks.galleryItemNamesGate; + return { items: mocks.galleryItemNames, total: mocks.galleryItemNames.length }; + }, + staleTime: Infinity, + }; + }, })); vi.mock('@features/gallery/contracts', async (importOriginal) => ({ @@ -387,6 +517,15 @@ const FollowProbe = () => { return ; }; +let headerPosition: PreviewItemPosition | null = null; +const HeaderPositionProbe = () => { + const { position } = usePreviewHeaderContext(); + const ref = useCallback(() => { + headerPosition = position; + }, [position]); + return ; +}; + // The shell's sensors, so touch on the preview arbitrates between swipe and drag as it does in the app. const ShellDndContext = ({ children }: Pick) => { const sensors = useSensors( @@ -421,6 +560,7 @@ const renderTree = async (client: QueryClient) => { + @@ -435,7 +575,7 @@ const renderTree = async (client: QueryClient) => { }); }; -const render = async () => { +const render = async (client = new QueryClient()) => { // A fresh mount replaces the last one: two mounted hotkey runtimes would each handle every key. await act(async () => { root?.unmount(); @@ -447,8 +587,6 @@ const render = async () => { document.body.append(host); root = createRoot(host); - const client = new QueryClient(); - queryClient = client; await renderTree(client); }; @@ -526,12 +664,12 @@ const legacyImage = (name: string, queuedAt: string, sourceQueueItemId = `queue- const deepBoardPages = (deep: GalleryImageItem[], next: GalleryImageItem[] = []) => Array.from({ length: 32 }, (_unused, index) => { if (index === 30) { - return { items: deep, total: deep.length + next.length }; + return { items: deep, total: 31 * 60 + deep.length + next.length }; } return index === 31 - ? { items: next, total: deep.length + next.length } - : { items: [], total: deep.length + next.length }; + ? { items: next, total: 31 * 60 + deep.length + next.length } + : { items: [], total: 31 * 60 + deep.length + next.length }; }); const getBoundary = (): HTMLElement => { @@ -614,6 +752,7 @@ beforeEach(() => { mocks.commands.account.updateProjectPreferences.mockClear(); mocks.commands.gallery.selectImage.mockClear(); mocks.commands.gallery.selectItem.mockClear(); + vi.mocked(requestGalleryItemReveal).mockClear(); mocks.project.queue.items = []; mocks.project.settings.showProgressImagesInViewer = false; delete (mocks.project.widgetInstances.gallery.state.values as Record).compareImage; @@ -634,7 +773,14 @@ beforeEach(() => { mocks.galleryStripFetches.length = 0; mocks.galleryStripItems = []; mocks.galleryItemPageOffsets.length = 0; - mocks.galleryItemWindowOffsets.length = 0; + mocks.galleryItemPageQueryKeys.length = 0; + mocks.galleryPageFetchSignals.length = 0; + mocks.deferredPageFetches.clear(); + mocks.verifiedGalleryPage = null; + mocks.verifiedGalleryPageFetches.length = 0; + mocks.galleryItemNames = []; + mocks.galleryItemNamesGate = null; + mocks.galleryItemNamesOptionCalls = 0; mocks.imageActionOptions = null; mocks.galleryItemPages = [ { @@ -677,6 +823,672 @@ const selectedThumb = (): string | null | undefined => host?.querySelector('button[aria-current]')?.getAttribute('aria-label'); describe('preview keyboard navigation boundary', () => { + it.each(['starred strip', 'local recent'] as const)( + 'waits for a missing preceding page before stepping to a %s item', + async (interveningItem) => { + const previousItems = Array.from({ length: 60 }, (_, index) => + createImageItem(`page-item-${index + 60}`, new Date(Date.UTC(2026, 7, 1) - (index + 60) * 1_000).toISOString()) + ); + const selected = createImageItem('page-item-120', new Date(Date.UTC(2026, 7, 1) - 120 * 1_000).toISOString()); + const starred = { ...createImageItem('starred-older', '2026-07-01T00:00:00.000Z'), starred: true }; + const recent = legacyImage('recent-newer', '2026-09-01T00:00:00.000Z'); + let resolvePrevious!: (page: GalleryItemsPage) => void; + const previousPage = new Promise((resolve) => { + resolvePrevious = resolve; + }); + + mocks.galleryItemPages = [ + { items: [], total: 180 }, + { items: previousItems, total: 180 }, + { items: [selected], total: 180 }, + ]; + mocks.galleryStripItems = interveningItem === 'starred strip' ? [starred] : []; + mocks.deferredPageFetches.set(60, { promise: previousPage, resolve: resolvePrevious }); + setGalleryValues({ + galleryPage: 2, + recentImages: interveningItem === 'local recent' ? [recent] : [], + selectedImage: selected, + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 2 }, + }); + + await render(); + await vi.waitFor(() => expect(mocks.galleryItemPageOffsets).toContain(60)); + await pressArrow('ArrowLeft'); + + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + expect(mocks.galleryPageFetchSignals).toHaveLength(1); + + await act(async () => { + resolvePrevious({ items: previousItems, offset: 60, total: 180 }); + await Promise.resolve(); + }); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'page-item-119' }), + undefined, + 1, + true + ) + ); + } + ); + + it.each(['arrow key', 'swipe'] as const)( + 'waits for a missing succeeding page before a %s steps to a newer local item in oldest-first order', + async (input) => { + const pageItem = (index: number) => + createImageItem(`page-item-${index}`, new Date(Date.UTC(2026, 7, 1) + index * 1_000).toISOString()); + // Stored newest first; the mock reverses each page for oldest-first listings. + const storedPage = (start: number) => ({ + items: Array.from({ length: 60 }, (_, index) => pageItem(start + 59 - index)), + total: 180, + }); + const nextItems = Array.from({ length: 60 }, (_, index) => pageItem(index + 120)); + const selected = pageItem(119); + let resolveNext!: (page: GalleryItemsPage) => void; + const nextPage = new Promise((resolve) => { + resolveNext = resolve; + }); + + mocks.galleryItemPages = [storedPage(0), storedPage(60), storedPage(120)]; + mocks.deferredPageFetches.set(120, { promise: nextPage, resolve: resolveNext }); + setGalleryValues({ + galleryPage: 1, + recentImages: [legacyImage('recent-newer', '2026-09-01T00:00:00.000Z')], + selectedImage: selected, + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, imageOrderDir: 'ASC', page: 1 }, + }); + + await render(); + await vi.waitFor(() => expect(mocks.galleryItemPageOffsets).toContain(120)); + + if (input === 'arrow key') { + await pressArrow('ArrowRight'); + } else { + await flickPreview(1); + } + + // A swipe commits once its settle animation ends; either way the step waits on the pending page. + await vi.waitFor(() => expect(mocks.galleryPageFetchSignals).toHaveLength(1)); + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + + await act(async () => { + resolveNext({ items: nextItems, offset: 120, total: 180 }); + await Promise.resolve(); + }); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: 'page-item-120' }), + undefined, + 2, + true + ) + ); + } + ); + + it('retries a failed succeeding page before stepping to a newer local item in oldest-first order', async () => { + const pageItem = (index: number) => + createImageItem(`page-item-${index}`, new Date(Date.UTC(2026, 7, 1) + index * 1_000).toISOString()); + const storedPage = (start: number) => ({ + items: Array.from({ length: 60 }, (_, index) => pageItem(start + 59 - index)), + total: 180, + }); + const nextItems = Array.from({ length: 60 }, (_, index) => pageItem(index + 120)); + const selected = pageItem(119); + let rejectNext!: (error: Error) => void; + const failedPage = new Promise((_resolve, reject) => { + rejectNext = reject; + }); + let resolveRetry!: (page: GalleryItemsPage) => void; + const retriedPage = new Promise((resolve) => { + resolveRetry = resolve; + }); + + mocks.galleryItemPages = [storedPage(0), storedPage(60), storedPage(120)]; + mocks.deferredPageFetches.set(120, { promise: failedPage, reject: rejectNext, resolve: () => {} }); + setGalleryValues({ + galleryPage: 1, + recentImages: [legacyImage('recent-newer', '2026-09-01T00:00:00.000Z')], + selectedImage: selected, + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, imageOrderDir: 'ASC', page: 1 }, + }); + + await render(new QueryClient({ defaultOptions: { queries: { retry: false } } })); + await vi.waitFor(() => expect(mocks.galleryItemPageOffsets).toContain(120)); + await act(async () => { + rejectNext(new Error('temporary page failure')); + await Promise.resolve(); + }); + + mocks.deferredPageFetches.set(120, { promise: retriedPage, resolve: resolveRetry }); + await pressArrow('ArrowRight'); + + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + await vi.waitFor(() => + expect(mocks.galleryItemPageOffsets.filter((pageOffset) => pageOffset === 120)).toHaveLength(2) + ); + await act(async () => { + resolveRetry({ items: nextItems, offset: 120, total: 180 }); + await Promise.resolve(); + }); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: 'page-item-120' }), + undefined, + 2, + true + ) + ); + }); + + it('retries a failed preceding page before entering a recent section', async () => { + const previousItems = Array.from({ length: 60 }, (_, index) => + createImageItem(`page-item-${index + 60}`, new Date(Date.UTC(2026, 7, 1) - (index + 60) * 1_000).toISOString()) + ); + const selected = createImageItem('page-item-120', new Date(Date.UTC(2026, 7, 1) - 120 * 1_000).toISOString()); + const recent = legacyImage('recent-newer', '2026-09-01T00:00:00.000Z'); + let rejectPrevious!: (error: Error) => void; + const failedPage = new Promise((_resolve, reject) => { + rejectPrevious = reject; + }); + let resolveRetry!: (page: GalleryItemsPage) => void; + const retriedPage = new Promise((resolve) => { + resolveRetry = resolve; + }); + + mocks.galleryItemPages = [ + { items: [], total: 180 }, + { items: previousItems, total: 180 }, + { items: [selected], total: 180 }, + ]; + mocks.deferredPageFetches.set(60, { promise: failedPage, reject: rejectPrevious, resolve: () => {} }); + setGalleryValues({ + galleryPage: 2, + recentImages: [recent], + selectedImage: selected, + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 2 }, + }); + + await render(new QueryClient({ defaultOptions: { queries: { retry: false } } })); + await vi.waitFor(() => expect(mocks.galleryItemPageOffsets).toContain(60)); + await act(async () => { + rejectPrevious(new Error('temporary page failure')); + await Promise.resolve(); + }); + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + + mocks.deferredPageFetches.set(60, { promise: retriedPage, resolve: resolveRetry }); + await pressArrow('ArrowLeft'); + + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + await vi.waitFor(() => + expect(mocks.galleryItemPageOffsets.filter((pageOffset) => pageOffset === 60)).toHaveLength(2) + ); + await act(async () => { + resolveRetry({ items: previousItems, offset: 60, total: 180 }); + await Promise.resolve(); + }); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'page-item-119' }), + undefined, + 1, + true + ) + ); + }); + + it('uses refreshed absolute positions when retrying a failed page with retained data', async () => { + const previousItems = Array.from({ length: 60 }, (_, index) => + createImageItem(`page-item-${index + 60}`, new Date(Date.UTC(2026, 7, 1) - (index + 60) * 1_000).toISOString()) + ); + const selected = createImageItem('page-item-120', new Date(Date.UTC(2026, 7, 1) - 120 * 1_000).toISOString()); + const recent = legacyImage('recent-newer', '2026-09-01T00:00:00.000Z'); + let rejectRefetch!: (error: Error) => void; + const failedRefetch = new Promise((_resolve, reject) => { + rejectRefetch = reject; + }); + let resolveRetry!: (page: GalleryItemsPage) => void; + const retriedPage = new Promise((resolve) => { + resolveRetry = resolve; + }); + mocks.galleryItemPages = [ + { items: [], total: 180 }, + { itemIndices: previousItems.map((_item, index) => index + 60), items: previousItems, total: 180 }, + { items: [selected], total: 180 }, + ]; + setGalleryValues({ + galleryPage: 2, + recentImages: [recent], + selectedImage: selected, + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 2 }, + }); + + await render(new QueryClient({ defaultOptions: { queries: { retry: false } } })); + await vi.waitFor(() => expect(mocks.galleryItemPageOffsets).toContain(60)); + const previousPageQueryKey = mocks.galleryItemPageQueryKeys.find(({ offset }) => offset === 60)?.queryKey; + expect(previousPageQueryKey).toBeDefined(); + expect(queryClient?.getQueryData(previousPageQueryKey!)?.itemIndices?.at(-1)).toBe(119); + + mocks.deferredPageFetches.set(60, { promise: failedRefetch, reject: rejectRefetch, resolve: () => {} }); + await act(async () => { + void queryClient?.invalidateQueries({ queryKey: previousPageQueryKey }); + await Promise.resolve(); + }); + await vi.waitFor(() => + expect(mocks.galleryItemPageOffsets.filter((pageOffset) => pageOffset === 60)).toHaveLength(2) + ); + await act(async () => { + rejectRefetch(new Error('temporary refresh failure')); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(queryClient?.getQueryState(previousPageQueryKey!)?.error).toBeInstanceOf(Error)); + expect(queryClient?.getQueryData(previousPageQueryKey!)).toBeDefined(); + + mocks.deferredPageFetches.set(60, { promise: retriedPage, resolve: resolveRetry }); + await pressArrow('ArrowLeft'); + await vi.waitFor(() => + expect(mocks.galleryItemPageOffsets.filter((pageOffset) => pageOffset === 60)).toHaveLength(3) + ); + await act(async () => { + resolveRetry({ + itemIndices: previousItems.map((_item, index) => (index === 59 ? 138 : index + 60)), + items: previousItems, + offset: 60, + total: 180, + }); + await Promise.resolve(); + }); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'page-item-119' }), + undefined, + 1, + true + ) + ); + expect(requestGalleryItemReveal).toHaveBeenLastCalledWith(expect.any(String), expect.any(AbortSignal), 138); + }); + + it('re-resolves a deleted-prefix selection by key before stepping and stamps its updated page', async () => { + const selected = createImageItem('survivor', '2026-07-20T00:00:01.000Z'); + const neighbor = createImageItem('preceding-survivor', '2026-07-20T00:00:02.000Z'); + const resolvedPage = { items: [neighbor, selected], offset: 60, total: 62 }; + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('survivor', selected.createdAt), + selectedImageName: 'survivor', + selectedImageQuery: { ...deepQuery, page: 5 }, + }); + mocks.galleryItemPages = Array.from({ length: 6 }, (_unused, index) => ({ + items: index === 5 ? [] : [], + total: 62, + })); + mocks.verifiedGalleryPage = { index: 61, offset: 60, page: resolvedPage, total: 62 }; + + await render(); + await pressArrow('ArrowLeft'); + + expect(mocks.verifiedGalleryPageFetches.map(({ ref }) => ref)).toEqual([{ kind: 'image', name: 'survivor' }]); + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'image', name: 'preceding-survivor' }), + undefined, + 1, + true + ); + }); + + it('re-resolves a reordered selection when its page stamp is stale although total is unchanged', async () => { + const selected = createImageItem('reordered', '2026-07-20T00:00:01.000Z'); + const neighbor = createImageItem('reordered-neighbor', '2026-07-20T00:00:02.000Z'); + const resolvedPage = { items: [neighbor, selected], offset: 0, total: 600 }; + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('reordered', selected.createdAt), + selectedImageName: 'reordered', + selectedImageQuery: { ...deepQuery, page: 5 }, + }); + mocks.galleryItemPages = Array.from({ length: 10 }, () => ({ items: [], total: 600 })); + mocks.verifiedGalleryPage = { index: 1, offset: 0, page: resolvedPage, total: 600 }; + + await render(); + await pressArrow('ArrowLeft'); + + expect(mocks.verifiedGalleryPageFetches).toHaveLength(1); + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'image', name: 'reordered-neighbor' }), + undefined, + 0, + true + ); + }); + + it('continues across the adjacent page when the verified selection is at a page boundary', async () => { + const selected = createImageItem('boundary-reordered', '2026-07-20T00:00:01.000Z'); + const neighbor = createImageItem('boundary-reordered-neighbor', '2026-07-19T00:00:00.000Z'); + const resolvedPage = { items: [selected], offset: 60, total: 180 }; + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('boundary-reordered', selected.createdAt), + selectedImageName: 'boundary-reordered', + selectedImageQuery: { ...deepQuery, page: 5 }, + }); + mocks.galleryItemPages = Array.from({ length: 6 }, (_unused, index) => ({ + items: index === 2 ? [neighbor] : [], + total: 180, + })); + mocks.verifiedGalleryPage = { index: 119, offset: 60, page: resolvedPage, total: 180 }; + + await render(); + await pressArrow('ArrowRight'); + + expect(mocks.verifiedGalleryPageFetches).toHaveLength(1); + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'image', name: 'boundary-reordered-neighbor' }), + undefined, + 2, + true + ); + }); + + it('loads the preceding listing page after a relocated selection moves to a page start', async () => { + const previous = createImageItem('relocated-predecessor', '2026-07-19T23:59:00.000Z'); + const selected = createImageItem('relocated-selected', '2026-07-19T23:58:00.000Z'); + const fillers = Array.from({ length: 59 }, (_unused, index) => + createImageItem(`relocated-${index}`, new Date(Date.UTC(2026, 6, 20, 0, 1) - index * 1000).toISOString()) + ); + const starred = { ...createImageItem('relocated-starred', '2026-07-21T00:00:00.000Z'), starred: true }; + + setGalleryValues({ + galleryPage: 1, + recentImages: [], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 1, paginationMode: 'paginated' }, + }); + mocks.galleryStripItems = [starred]; + mocks.galleryItemPages = [ + { items: [...fillers, previous], total: 120 }, + { items: [createImageItem('stale-page-one', '2026-07-19T23:57:00.000Z')], total: 120 }, + ]; + mocks.verifiedGalleryPage = { index: 60, offset: 60, page: { items: [selected], total: 120 }, total: 120 }; + + await render(); + const previousPageQuery = mocks.galleryItemPageQueryKeys.find(({ offset }) => offset === 0); + expect(previousPageQuery).toBeDefined(); + await act(() => { + // Simulate a cached page from before an insertion moved the selected item across the page boundary. + queryClient!.setQueryData(previousPageQuery!.queryKey, { + items: [...fillers, selected], + offset: 0, + total: 120, + }); + }); + await pressArrow('ArrowLeft'); + + expect(mocks.verifiedGalleryPageFetches).toHaveLength(1); + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'image', name: previous.name }), + undefined, + 0, + true + ); + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'image', name: starred.name }), + expect.anything(), + expect.anything(), + expect.anything() + ); + }); + + it('loads the succeeding listing page before a recent after a relocated selection moves to a page end', async () => { + // Oldest first, so the recent result sorts after every saved item. + const pageItem = (index: number) => + createImageItem(`relocated-${index}`, new Date(Date.UTC(2026, 6, 1) + index * 1_000).toISOString()); + const selected = pageItem(119); + const located = Array.from({ length: 60 }, (_unused, index) => pageItem(60 + index)); + + setGalleryValues({ + galleryPage: 1, + recentImages: [legacyImage('recent-newer', '2026-09-01T00:00:00.000Z')], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, imageOrderDir: 'ASC', page: 0 }, + }); + // Stored newest first; the mock reverses each page for oldest-first listings. + mocks.galleryItemPages = [ + { items: [], total: 180 }, + { items: [], total: 180 }, + { items: Array.from({ length: 60 }, (_unused, index) => pageItem(179 - index)), total: 180 }, + ]; + mocks.verifiedGalleryPage = { index: 119, offset: 60, page: { items: located, total: 180 }, total: 180 }; + + await render(); + await pressArrow('ArrowRight'); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ kind: 'image', name: 'relocated-120' }), + undefined, + 2, + true + ) + ); + }); + + it('swipes toward a pending neighbor, not the loaded guess, while the selection must be relocated', async () => { + const newer = createImageItem('stale-newer', '2026-07-20T00:00:03.000Z'); + const selected = createImageItem('moved-away', '2026-07-20T00:00:02.000Z'); + const older = createImageItem('stale-older', '2026-07-20T00:00:01.000Z'); + const relocatedNext = createImageItem('relocated-next', '2026-07-19T00:00:00.000Z'); + + setGalleryValues({ + recentImages: [], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 0 }, + }); + mocks.galleryItemPages = [ + { items: [newer, older], total: 62 }, + { items: [], total: 62 }, + ]; + mocks.verifiedGalleryPage = { + index: 60, + offset: 60, + page: { items: [selected, relocatedNext], total: 62 }, + total: 62, + }; + + await render(); + await expect.poll(() => selectedThumb()).toBe(selected.name); + await flickPreview(1); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: relocatedNext.name }), + undefined, + 1, + true + ) + ); + expect( + [...host!.querySelectorAll('[data-swipe-neighbor="next"] img')].map((image) => image.getAttribute('src')) + ).not.toContain(older.fullUrl); + }); + + it('does not select a missing key when verified lookup returns no result', async () => { + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('gone', '2026-07-20T00:00:01.000Z'), + selectedImageName: 'gone', + selectedImageQuery: { ...deepQuery, page: 5 }, + }); + mocks.galleryItemPages = Array.from({ length: 6 }, () => ({ items: [], total: 62 })); + + await render(); + await pressArrow('ArrowLeft'); + + expect(mocks.verifiedGalleryPageFetches).toHaveLength(1); + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + }); + + it('keeps date-board selections off the ordinary listing locator', async () => { + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('date-board-item', '2026-07-20T00:00:01.000Z'), + selectedImageName: 'date-board-item', + selectedImageQuery: { ...deepQuery, boardId: 'by_date:2026-07-20', page: 5 }, + }); + mocks.galleryItemPages = Array.from({ length: 6 }, () => ({ items: [], total: 62 })); + + await render(); + await pressArrow('ArrowLeft'); + + expect(mocks.verifiedGalleryPageFetches).toHaveLength(0); + }); + + it.each([ + ['search filter', () => ({ selectedImageQuery: { ...deepQuery, page: 0, searchTerm: 'changed' } })], + ['sort order', () => ({ selectedImageQuery: { ...deepQuery, page: 0, imageOrderDir: 'ASC' } })], + [ + 'selected item', + () => ({ + selectedImage: legacyImage('context-changed', '2026-07-19T00:00:00.000Z'), + selectedImageName: 'context-changed', + }), + ], + ] as Array<[string, () => Record]>)( + 'aborts a deferred boundary read when the %s changes and ignores its late result', + async (_transition, getPatch) => { + const selected = createImageItem('boundary-selected', '2026-07-20T00:00:01.000Z'); + let resolve!: (page: GalleryItemsPage) => void; + const promise = new Promise((done) => { + resolve = done; + }); + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('boundary-selected', selected.createdAt), + selectedImageName: 'boundary-selected', + selectedImageQuery: { ...deepQuery, page: 0 }, + }); + mocks.galleryItemPages = Array.from({ length: 3 }, (_unused, index) => ({ + items: index === 0 ? [selected] : [], + total: 180, + })); + mocks.deferredPageFetches.set(120, { promise, resolve }); + + await render(); + await pressArrow('ArrowRight'); + await expect.poll(() => mocks.galleryPageFetchSignals.length > 0).toBe(true); + const signal = mocks.galleryPageFetchSignals.at(-1)!; + expect(signal.aborted).toBe(false); + + setGalleryValues(getPatch()); + await rerender(); + expect(signal.aborted).toBe(true); + resolve({ items: [createImageItem('late-boundary', '2026-07-19T00:00:00.000Z')], offset: 120, total: 180 }); + await act(() => Promise.resolve()); + + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + } + ); + + it('aborts a deferred boundary read on unmount and ignores its late result', async () => { + const selected = createImageItem('unmount-selected', '2026-07-20T00:00:01.000Z'); + let resolve!: (page: GalleryItemsPage) => void; + const promise = new Promise((done) => { + resolve = done; + }); + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('unmount-selected', selected.createdAt), + selectedImageName: 'unmount-selected', + selectedImageQuery: { ...deepQuery, page: 0 }, + }); + mocks.galleryItemPages = Array.from({ length: 3 }, (_unused, index) => ({ + items: index === 0 ? [selected] : [], + total: 180, + })); + mocks.deferredPageFetches.set(120, { promise, resolve }); + + await render(); + await pressArrow('ArrowRight'); + await expect.poll(() => mocks.galleryPageFetchSignals.length > 0).toBe(true); + const signal = mocks.galleryPageFetchSignals.at(-1)!; + + await act(() => { + root?.unmount(); + }); + expect(signal.aborted).toBe(true); + resolve({ items: [createImageItem('late-unmount', '2026-07-19T00:00:00.000Z')], offset: 120, total: 180 }); + await act(() => Promise.resolve()); + + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + }); + + it('ignores an already-resolved boundary result after the account scope changes', async () => { + const firstScope = accountLifecycle.activate('preview-navigation-account-a'); + const selected = createImageItem('account-selected', '2026-07-20T00:00:01.000Z'); + let resolve!: (page: GalleryItemsPage) => void; + const promise = new Promise((done) => { + resolve = done; + }); + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('account-selected', selected.createdAt), + selectedImageName: 'account-selected', + selectedImageQuery: { ...deepQuery, page: 0 }, + }); + mocks.galleryItemPages = Array.from({ length: 3 }, (_unused, index) => ({ + items: index === 0 ? [selected] : [], + total: 180, + })); + mocks.deferredPageFetches.set(120, { ignoreAbort: true, promise, resolve }); + + await render(); + await pressArrow('ArrowRight'); + await expect.poll(() => mocks.galleryPageFetchSignals.length > 0).toBe(true); + const signal = mocks.galleryPageFetchSignals.at(-1)!; + expect(signal.aborted).toBe(false); + + await act(async () => { + // Resolve the request first, then rotate the account before the awaiting Preview continuation runs. + resolve({ + items: [createImageItem('late-account-boundary', '2026-07-19T00:00:00.000Z')], + offset: 120, + total: 180, + }); + accountLifecycle.activate('preview-navigation-account-b'); + await new Promise((done) => { + setTimeout(() => done(), 0); + }); + }); + + expect(firstScope.signal.aborted).toBe(true); + expect(signal.aborted).toBe(true); + expect(mocks.commands.gallery.selectItem).not.toHaveBeenCalled(); + accountLifecycle.invalidate(); + }); + it('walks the starred strip into the unstarred listing and back, as the grid lays them out', async () => { const starredTop = { ...createImageItem('starred-top', '2026-07-23T00:00:00.000Z'), starred: true }; const starredNext = { ...createImageItem('starred-next', '2026-07-22T00:00:00.000Z'), starred: true }; @@ -753,6 +1565,7 @@ describe('preview keyboard navigation boundary', () => { selectedImageName: 'starred-deep', }); await render(); + await expect.poll(() => selectedThumb()).toBe('starred-deep'); await pressArrow('ArrowLeft'); expect(mocks.commands.gallery.selectItem).toHaveBeenLastCalledWith( expect.objectContaining({ name: 'starred-top' }), @@ -854,7 +1667,28 @@ describe('preview keyboard navigation boundary', () => { await render(); await pressArrow('ArrowRight'); - expect(vi.mocked(requestGalleryItemReveal)).toHaveBeenCalledWith('image:oldest'); + expect(vi.mocked(requestGalleryItemReveal)).toHaveBeenCalledWith('image:oldest', expect.any(AbortSignal), 1); + }); + + it('reveals an ordinary deep-page navigation at its absolute sparse listing index', async () => { + const deepNewer = createImageItem('deep-newer', '2026-07-20T00:00:02.000Z'); + const deepOlder = createImageItem('deep-older', '2026-07-20T00:00:01.000Z'); + + setGalleryValues({ + galleryPage: 0, + recentImages: [], + selectedImage: legacyImage('deep-newer', deepNewer.createdAt), + selectedImageName: 'deep-newer', + selectedImageQuery: deepQuery, + }); + mocks.galleryItemPages = deepBoardPages([deepNewer, deepOlder]); + mocks.galleryItemPages[30] = { ...mocks.galleryItemPages[30]!, itemIndices: [1801, 1806] }; + + await render(); + await pressArrow('ArrowRight'); + + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith(deepOlder, undefined, 30, true); + expect(vi.mocked(requestGalleryItemReveal)).toHaveBeenCalledWith('image:deep-older', expect.any(AbortSignal), 1806); }); it('keeps a just-completed batch navigable before the backend refetch lands', async () => { @@ -1048,9 +1882,7 @@ describe('preview keyboard navigation boundary', () => { ); }); - it('holds a deep window still while the cursor walks across a page boundary and back', async () => { - // Preserve the original infinite-window anchor across boundary steps so earlier traversed pages remain - // reachable. + it('loads only selected and adjacent absolute pages while the cursor crosses a deep page boundary', async () => { const deepA = createImageItem('deep-a', '2026-07-20T00:00:04.000Z'); const deepB = createImageItem('deep-b', '2026-07-20T00:00:03.000Z'); const deepC = createImageItem('deep-c', '2026-07-20T00:00:02.000Z'); @@ -1081,13 +1913,12 @@ describe('preview keyboard navigation boundary', () => { ]); expect(selected).toEqual([ - ['deep-c', 30], - ['deep-d', 30], - ['deep-c', 30], + ['deep-c', 31], + ['deep-d', 31], + ['deep-c', 31], ['deep-b', 30], ]); - expect(mocks.galleryItemWindowOffsets).not.toContain(1860); - expect(mocks.galleryItemWindowOffsets).not.toContain(0); + expect(mocks.galleryItemPageOffsets.sort((a, b) => a - b)).toEqual([1740, 1800, 1860]); }); it('moves to the top of the listing when a selection is made there from outside Preview', async () => { @@ -1108,9 +1939,9 @@ describe('preview keyboard navigation boundary', () => { await render(); - expect(mocks.galleryItemWindowOffsets).toContain(1800); + expect(mocks.galleryItemPageOffsets).toContain(1800); - mocks.galleryItemWindowOffsets.length = 0; + mocks.galleryItemPageOffsets.length = 0; setGalleryValues({ selectedImage: legacyImage('top-newer', '2026-07-22T00:00:02.000Z'), selectedImageName: 'top-newer', @@ -1119,7 +1950,7 @@ describe('preview keyboard navigation boundary', () => { await rerender(); await pressArrow('ArrowRight'); - expect(mocks.galleryItemWindowOffsets).not.toContain(1800); + expect(mocks.galleryItemPageOffsets).not.toContain(1800); expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( expect.objectContaining({ kind: 'image', name: 'top-older' }), undefined, @@ -1144,6 +1975,9 @@ describe('preview keyboard navigation boundary', () => { await render(); + await expect + .poll(() => mocks.imageActionOptions?.getItemActionContext?.().items.map((item) => item.name)) + .toContain('deep-older'); const context = mocks.imageActionOptions?.getItemActionContext?.(); expect(context?.getItemSelectionPage?.(deepOlder)).toBe(30); @@ -1269,10 +2103,72 @@ describe('preview keyboard navigation boundary', () => { ); }); - it('hands image actions the window anchor for an item on a later page of the window', async () => { - // The stamp is the anchor of the window holding the item, not the page - // the item happens to sit on: a successor from page 31 of a window - // anchored at page 30 is stamped 30. + it('reports the selection position in the whole board while only the pages around it are loaded', async () => { + const pageItem = (index: number) => + createImageItem(`position-${index}`, new Date(Date.UTC(2026, 6, 1) - index * 1_000).toISOString()); + const selected = pageItem(330); + + mocks.galleryItemPages = Array.from({ length: 7 }, (_unused, page) => ({ + items: page >= 4 ? Array.from({ length: 60 }, (_item, index) => pageItem(page * 60 + index)) : [], + total: 420, + })); + setGalleryValues({ + recentImages: [], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 5 }, + }); + + await render(); + + await expect.poll(() => headerPosition).toEqual({ boardItemCount: 420, isLoadingBoard: false, selectedIndex: 330 }); + }); + + it('reports no position for a selection its stamped page does not hold until a step locates it', async () => { + const pageItem = (index: number) => + createImageItem(`unlocated-${index}`, new Date(Date.UTC(2026, 6, 1) - index * 1_000).toISOString()); + const selected = pageItem(330); + + // Stamped on page 0 (as a selection made outside the Gallery is), but the item sits on page 5. + mocks.galleryItemPages = Array.from({ length: 7 }, (_unused, page) => ({ + items: Array.from({ length: 60 }, (_item, index) => pageItem(page * 60 + index)), + total: 420, + })); + setGalleryValues({ + recentImages: [], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 0 }, + }); + + await render(); + + await expect.poll(() => headerPosition).toEqual({ boardItemCount: 420, isLoadingBoard: false, selectedIndex: -1 }); + }); + + it('keeps the position of a selection that moved onto an adjacent loaded page', async () => { + const pageItem = (index: number) => + createImageItem(`shifted-${index}`, new Date(Date.UTC(2026, 6, 1) - index * 1_000).toISOString()); + const selected = pageItem(330); + + // Stamped on page 4, but an insert pushed it onto page 5, which Preview loads beside page 4. + mocks.galleryItemPages = Array.from({ length: 7 }, (_unused, page) => ({ + items: Array.from({ length: 60 }, (_item, index) => pageItem(page * 60 + index)), + total: 420, + })); + setGalleryValues({ + recentImages: [], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 4 }, + }); + + await render(); + + await expect.poll(() => headerPosition).toEqual({ boardItemCount: 420, isLoadingBoard: false, selectedIndex: 330 }); + }); + + it('hands image actions the selected item absolute page', async () => { const deepA = createImageItem('deep-a', '2026-07-20T00:00:04.000Z'); const deepB = createImageItem('deep-b', '2026-07-20T00:00:03.000Z'); const deepC = createImageItem('deep-c', '2026-07-20T00:00:02.000Z'); @@ -1287,14 +2183,14 @@ describe('preview keyboard navigation boundary', () => { mocks.galleryItemPages = deepBoardPages([deepA, deepB], [deepC]); await render(); - // Cross the boundary so page 31 is part of the window. + // Cross the boundary so page 31 is in the shared page cache. await pressArrow('ArrowRight'); await commitLastSelection(); const context = mocks.imageActionOptions?.getItemActionContext?.(); expect(context?.items.map((item) => item.name)).toContain('deep-c'); - expect(context?.getItemSelectionPage?.(deepC)).toBe(30); + expect(context?.getItemSelectionPage?.(deepC)).toBe(31); }); it('does not restore a remembered page for an item since moved to another board', async () => { @@ -1368,7 +2264,7 @@ describe('preview keyboard navigation boundary', () => { width: newest.width, }, ], - total: 2, + total: 61, }, { items: [ @@ -1387,7 +2283,7 @@ describe('preview keyboard navigation boundary', () => { width: oldest.width, }, ], - total: 2, + total: 61, }, ]; @@ -1402,7 +2298,7 @@ describe('preview keyboard navigation boundary', () => { true ); }); - expect(mocks.galleryItemPageOffsets).toEqual([60]); + expect(mocks.galleryItemPageOffsets.sort((a, b) => a - b)).toEqual([0, 60]); expect(mocks.commands.gallery.selectItem).toHaveBeenCalledTimes(1); }); @@ -1483,7 +2379,7 @@ describe('preview keyboard navigation boundary', () => { ); }); - it('walks the flat chronological order on paginated pages instead of lifting starred items', async () => { + it('keeps paginated Preview inside the unstarred listing', async () => { const galleryValues = mocks.project.widgetInstances.gallery.state.values as Record; const selected = { ...mocks.recentImages[0], @@ -1513,9 +2409,9 @@ describe('preview keyboard navigation boundary', () => { await render(); await pressArrow('ArrowRight'); - // Under starred-first the step would land on oldest; flat pages win. + // The regular Gallery page query excludes starred items; the strip is not merged into this paginated result. expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'image', name: 'starred-mid' }), + expect.objectContaining({ kind: 'image', name: 'oldest' }), undefined, 0, true @@ -1614,9 +2510,22 @@ describe('preview keyboard navigation boundary', () => { expect(mocks.galleryItemFilters.at(-1)).toMatchObject({ boardId: 'board-a' }); expect(mocks.galleryItemFilters.at(-1)).not.toHaveProperty('semanticQuery'); + + // A pick made outside the Gallery (a search result) keeps its own board even while the Gallery ranks. + mocks.galleryItemFilters.length = 0; + setGalleryValues({ + semanticImageQuery: { kind: 'text', query: 'sunset' }, + selectedImageQuery: { ...(galleryValues.selectedImageQuery as object), itemBoard: true }, + }); + await rerender(); + + // Only Preview asks for board A; the Gallery's grid keeps ranking board B. + expect(mocks.galleryItemFilters.some((query) => query.boardId === 'board-a' && !('semanticQuery' in query))).toBe( + true + ); }); - it('stamps the top of the board listing for a ranked pick even when the footer paginates the ranking', async () => { + it('stamps the selected ranking page when the footer paginates semantic results', async () => { const filler = { ...mocks.recentImages[0], boardId: 'none', @@ -1654,8 +2563,8 @@ describe('preview keyboard navigation boundary', () => { width: image.width, }); - // Rank pages are not board pages; ranked picks return board page zero rather than the current ranking or stale - // selection page. + // The ranking page centers Preview subscriptions while semantic search remains active. Its semantic identity + // prevents the position from becoming an ordinary board page after the search ends. galleryValues.galleryPage = 1; galleryValues.paginationMode = 'paginated'; galleryValues.recentImages = []; @@ -1666,9 +2575,10 @@ describe('preview keyboard navigation boundary', () => { boardId: 'none', galleryView: 'images', imageOrderDir: 'DESC', - page: 30, + page: 1, paginationMode: 'paginated', searchTerm: '', + semanticKey: 'text:sunset', }; mocks.galleryItemPages = [ { items: [filler].map(toItem), total: 3 }, @@ -1681,7 +2591,7 @@ describe('preview keyboard navigation boundary', () => { expect(mocks.commands.gallery.selectItem).toHaveBeenCalledWith( expect.objectContaining({ kind: 'image', name: neighbor.imageName }), undefined, - 0, + 1, true ); }); @@ -1795,6 +2705,39 @@ describe('preview keyboard navigation boundary', () => { expect(mocks.commands.account.updateProjectPreferences).not.toHaveBeenCalled(); }); + it('steps right off the followed session onto the top of the board when the selection sits further down', async () => { + const pageItem = (index: number) => + createImageItem(`board-${index}`, new Date(Date.UTC(2026, 6, 1) - index * 1_000).toISOString()); + const selected = pageItem(330); + + mocks.galleryItemPages = Array.from({ length: 7 }, (_unused, page) => ({ + items: page === 0 || page >= 4 ? Array.from({ length: 60 }, (_item, index) => pageItem(page * 60 + index)) : [], + total: 420, + })); + setGalleryValues({ + recentImages: [], + selectedImage: legacyImage(selected.name, selected.createdAt), + selectedImageName: selected.name, + selectedImageQuery: { ...deepQuery, page: 5 }, + }); + mocks.project.queue.items = [{ ...queueItem, backendItemIds: [1, 2], completedBackendItemIds: [1] }]; + mocks.project.settings.showProgressImagesInViewer = true; + mocks.runningProgressTargets = [{ itemIndex: 2, queueItemId: queueItem.id }]; + + await render(); + await vi.waitFor(() => expect(mocks.galleryItemPageOffsets).toEqual(expect.arrayContaining([240, 300, 360]))); + await pressArrow('ArrowRight'); + + await vi.waitFor(() => + expect(mocks.commands.gallery.selectItem).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: 'board-0' }), + undefined, + 0, + true + ) + ); + }); + it('renders the live frame with the standard media chrome: footer up, no badge, item border', async () => { mocks.project.queue.items = [queueItem]; mocks.project.settings.showProgressImagesInViewer = true; @@ -2297,11 +3240,10 @@ describe('preview keyboard navigation boundary', () => { await render(); const video = host?.querySelector('video'); - const filmstripPosters = host?.querySelectorAll('button img'); expect(video?.getAttribute('src')).toBe(sameNameVideo.fullUrl); expect(video?.getAttribute('poster')).toBe(sameNameVideo.thumbnailUrl); - expect(filmstripPosters).toHaveLength(3); + await expect.poll(() => host?.querySelectorAll('button img').length).toBe(3); expect(host?.textContent).not.toContain('Drop to compare'); await pressArrow('ArrowLeft'); @@ -2366,19 +3308,86 @@ describe('preview keyboard navigation boundary', () => { galleryValues.selectedImage = sameNameVideo; galleryValues.selectedImageName = 'video:shared'; mocks.galleryItemPages = [{ items: [sameNameImage, sameNameVideo], total: 2 }]; + mocks.galleryItemNames = [sameNameImage, sameNameVideo].map(({ kind, name }) => ({ kind, name })); await render(); expect(mocks.imageActionOptions?.onImagesDeleted).toBeUndefined(); + await expect + .poll(() => mocks.imageActionOptions?.getItemActionContext?.().items) + .toEqual([sameNameImage, sameNameVideo]); const context = mocks.imageActionOptions?.getItemActionContext?.(); expect(context?.selectedItemKey).toBe('video:shared'); - expect(context?.items).toEqual([sameNameImage, sameNameVideo]); await expect(context?.loadOrderedRefs(new AbortController().signal)).resolves.toEqual([ { kind: 'image', name: 'shared' }, { kind: 'video', name: 'shared' }, ]); }); + it('lazily loads full ordered refs for a range extending past the sparse adjacent-page window', async () => { + const orderedItems = Array.from({ length: 240 }, (_unused, index) => + createImageItem( + `range-${String(index).padStart(3, '0')}`, + new Date(Date.UTC(2026, 6, 30, 0, 0, 240 - index)).toISOString() + ) + ); + + mocks.galleryItemPages = Array.from({ length: 4 }, (_unused, page) => ({ + items: orderedItems.slice(page * 60, (page + 1) * 60), + total: orderedItems.length, + })); + mocks.galleryItemNames = orderedItems.map(({ kind, name }) => ({ kind, name })); + setGalleryValues({ + recentImages: [], + selectedImage: legacyImage('range-060', orderedItems[60].createdAt), + selectedImageName: 'range-060', + selectedImageQuery: { ...deepQuery, page: 1 }, + }); + + await render(); + + await expect.poll(() => mocks.imageActionOptions?.getItemActionContext?.().items.length).toBe(180); + const context = mocks.imageActionOptions?.getItemActionContext?.(); + + expect(mocks.galleryItemNamesOptionCalls).toBe(0); + await expect(context?.loadOrderedRefs(new AbortController().signal)).resolves.toHaveLength(240); + expect(mocks.galleryItemNamesOptionCalls).toBe(1); + + const refs = await context?.loadOrderedRefs(new AbortController().signal); + + expect(refs?.slice(178, 182)).toEqual([ + { kind: 'image', name: 'range-178' }, + { kind: 'image', name: 'range-179' }, + { kind: 'image', name: 'range-180' }, + { kind: 'image', name: 'range-181' }, + ]); + }); + + it('keeps loading the ordered listing for a deletion that clears the selection first', async () => { + const before = createImageItem('before', '2026-07-30T13:00:00Z'); + const selected = createImageItem('selected', '2026-07-30T12:00:00Z'); + const after = createImageItem('after', '2026-07-30T11:00:00Z'); + let openNames!: () => void; + + mocks.galleryItemPages = [{ items: [before, selected, after], total: 3 }]; + mocks.galleryItemNames = [before, selected, after].map(({ kind, name }) => ({ kind, name })); + mocks.galleryItemNamesGate = new Promise((resolve) => { + openNames = resolve; + }); + setGalleryValues({ recentImages: [], selectedImage: selected, selectedImageName: 'image:selected' }); + + await render(); + await expect.poll(() => mocks.imageActionOptions?.getItemActionContext?.().items.length).toBe(3); + const refs = mocks.imageActionOptions!.getItemActionContext!().loadOrderedRefs(new AbortController().signal); + + // The deletion removes its primary optimistically while the order is still loading. + setGalleryValues({ selectedImage: null, selectedImageName: null }); + await rerender(); + openNames(); + + await expect(refs).resolves.toEqual(mocks.galleryItemNames); + }); + it('prefetches an image neighbor but never assigns a full video URL to Image', async () => { const previousImage = createImageItem('prefetch-previous', '2026-07-30T13:00:00Z'); const selectedImage = createImageItem('prefetch-selected', '2026-07-30T12:00:00Z'); @@ -2403,7 +3412,7 @@ describe('preview keyboard navigation boundary', () => { await render(); - expect(preloadedSources).toContain(previousImage.fullUrl); + await expect.poll(() => preloadedSources).toContain(previousImage.fullUrl); expect(preloadedSources).not.toContain(nextVideo.fullUrl); } finally { Object.defineProperty(globalThis, 'Image', { configurable: true, value: NativeImage, writable: true }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts index dc3ae834582..4907588e480 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.test.ts @@ -4,7 +4,63 @@ import { GALLERY_MAX_ROWS } from '@features/gallery/queries'; import { describe, expect, it } from 'vitest'; import { getVideoFrameCopyNotice } from './PreviewWidgetView'; -import { mergePreviewBoardItems } from './usePreviewNavigation'; +import { getPreviewSelectedPage, mergePreviewBoardItems } from './usePreviewNavigation'; + +describe('getPreviewSelectedPage', () => { + const selectedImageQuery = { + boardId: 'none', + galleryView: 'images' as const, + imageOrderDir: 'DESC' as const, + itemBoard: false, + page: 3, + paginationMode: 'infinite' as const, + searchTerm: '', + semanticKey: 'text:sunset', + starredOnly: false, + }; + + it('centers matching semantic navigation on the selected result page', () => { + expect( + getPreviewSelectedPage({ + galleryPage: 0, + navigationBoardId: 'none', + navigationSemanticKey: 'text:sunset', + selectedImageQuery, + }) + ).toBe(3); + }); + + it('does not reuse a result position after the semantic query changes', () => { + expect( + getPreviewSelectedPage({ + galleryPage: 0, + navigationBoardId: 'none', + navigationSemanticKey: 'text:dawn', + selectedImageQuery, + }) + ).toBe(0); + + expect( + getPreviewSelectedPage({ + galleryPage: 0, + navigationBoardId: 'different-board', + navigationSemanticKey: 'text:sunset', + selectedImageQuery, + }) + ).toBe(0); + }); + + it('does not reinterpret a semantic result page as an ordinary board page', () => { + expect( + getPreviewSelectedPage({ + galleryPage: 0, + navigationBoardId: 'none', + navigationSemanticKey: '', + selectedImageQuery, + }) + ).toBe(0); + }); +}); describe('mergePreviewBoardItems', () => { const item = (kind: GalleryItem['kind'], name: string, createdAt: string, starred = false): GalleryItem => { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index 0b0944a9138..aa2e6f3fe23 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -37,6 +37,7 @@ import { useQueueItemProgressImage, useQueueItemSwapProgressImage, } from '@features/queue/react'; +import { captureAccountScope } from '@platform/state/accountLifecycle'; import { imageUrlToStreamingSource, progressImageToStreamingSource, @@ -243,10 +244,10 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { ); const selectGalleryItemAtPage = useCallback( - (item: GalleryItem, selectionPage: number) => { + (item: GalleryItem, selectionPage: number, absoluteIndex?: number) => { gallery.selectItem(item, undefined, selectionPage, true); // Deliberate navigation: the grid follows it, unlike auto-selection. - requestGalleryItemReveal(toGalleryItemKey(item)); + requestGalleryItemReveal(toGalleryItemKey(item), captureAccountScope().signal, absoluteIndex); }, [gallery] ); @@ -254,10 +255,11 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { boardItems, getSelectionPage, isLoadingBoard, + loadOrderedRefs, navigate, - navigationCursor, navigationQueryKey, neighbors, + position, selectPreviewItem, stripItemCount, } = usePreviewNavigation({ @@ -288,13 +290,10 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { filterIdentity: navigationQueryKey, getItemSelectionPage: getSelectionPage, items: boardItems, - loadOrderedRefs: (signal: AbortSignal) => { - signal.throwIfAborted(); - return Promise.resolve(boardItems.map(toGalleryItemRef)); - }, + loadOrderedRefs, selectedItemKey, }), - [boardItems, getSelectionPage, navigationQueryKey, selectedItemKey] + [boardItems, getSelectionPage, loadOrderedRefs, navigationQueryKey, selectedItemKey] ); const projectId = useActiveProjectId(); const { dialog: deletionConfirmationDialog, requestDeletionConfirmation } = useDeletionConfirmation(); @@ -521,14 +520,13 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { itemName: headerItemName, openItemMenu: shouldFollowLive ? null : openItemContextMenu, position: hasHeaderItem - ? { boardItemCount: boardItems.length, isLoadingBoard, selectedIndex: navigationCursor } + ? { boardItemCount: position.total, isLoadingBoard, selectedIndex: position.index } : null, // Only the single image frame carries a loupe: videos have none and // compare has its own synced pair. zoom: hasHeaderItem && !isComparing && contextMenuItem.kind === 'image' ? zoomCommands : null, }); }, [ - boardItems.length, boardName, contextMenuItem, copyCurrentVideoFrame, @@ -538,8 +536,8 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { isComparing, isLoadingBoard, isVideoFrameCopyAvailable, - navigationCursor, openItemContextMenu, + position, shouldFollowLive, zoomCommands, ]); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts b/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts index 3f957de5c70..2ec5e3eb6e5 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/usePreviewNavigation.ts @@ -1,4 +1,4 @@ -import type { GalleryImageItem, GalleryItem, GalleryItemKey, GalleryView } from '@features/gallery'; +import type { GalleryImageItem, GalleryItem, GalleryItemKey, GalleryItemRef, GalleryView } from '@features/gallery'; import type { GalleryItemsPage, GalleryNavigationEntry, @@ -7,7 +7,6 @@ import type { } from '@features/gallery/contracts'; import type { GalleryItemsFilter } from '@features/gallery/queries'; import type { QueueItem, QueueProgressSession } from '@features/queue/contracts'; -import type { InfiniteData } from '@tanstack/react-query'; import { compareGalleryItems, @@ -15,28 +14,49 @@ import { getGalleryNavigationStep, getGallerySessionNavigationKey, toGalleryItemKey, + toGalleryItemRef, } from '@features/gallery/contracts'; import { - flattenGalleryItemsData, GALLERY_MAX_ROWS, GALLERY_PAGE_SIZE, - galleryItemsInfiniteOptions, + fetchGalleryItemsPage, + fetchVerifiedGalleryItemPage, + isDateBoardId, + galleryItemNamesOptions, + galleryItemsPageOptions, galleryStarredStripOptions, } from '@features/gallery/queries'; +import { useMountEffect } from '@platform/react/useMountEffect'; import { parseDateTokens } from '@platform/search/dateTokens'; -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; +import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; /** - * Own Preview query merging, cursor derivation, boundary fetches, and neighbor prefetch. Follow Gallery's - * session/starred/list order, but traverse the full bounded starred query beyond the grid's folded rows; - * selection/follow remain authoritative. + * Own Preview query merging, cursor derivation, boundary fetches, and neighbor prefetch. Shared absolute pages let + * Preview reach deep selections without replaying every earlier page. */ const EMPTY_PREVIEW_ITEMS: GalleryItem[] = []; -const flattenPreviewItems = (data: InfiniteData | undefined): GalleryItem[] => - flattenGalleryItemsData(data); +export const getPreviewSelectedPage = ({ + galleryPage, + navigationBoardId, + navigationSemanticKey, + selectedImageQuery, +}: { + galleryPage: number; + navigationBoardId: string; + navigationSemanticKey: string; + selectedImageQuery: ReturnType; +}): number => + navigationSemanticKey === '' + ? selectedImageQuery.semanticKey === null + ? selectedImageQuery.page + : 0 + : selectedImageQuery.boardId === navigationBoardId && selectedImageQuery.semanticKey === navigationSemanticKey + ? selectedImageQuery.page + : galleryPage; const getOrderedPreviewItems = ( items: GalleryItem[], @@ -132,7 +152,9 @@ const toNeighbor = (entry: GalleryNavigationEntry | null): PreviewNeighbor => ? null : entry.kind === 'item' ? { item: entry.item, kind: 'item' } - : { id: entry.id, kind: 'session' }; + : entry.kind === 'session' + ? { id: entry.id, kind: 'session' } + : null; export interface PreviewNavigationState { /** Every saved item the arrows can reach, in order: the starred strip, then the listing. */ @@ -142,12 +164,15 @@ export interface PreviewNavigationState { navigate: (offset: -1 | 1) => Promise; /** What each step would land on, so a swipe can show it before committing. */ neighbors: PreviewNeighbors; - /** The selection's index in `boardItems`; -1 while following live or off the list. */ - navigationCursor: number; + /** The selection's place in the whole strip-then-listing order (-1 while following live or off the list), and that + * order's length. */ + position: { index: number; total: number }; /** Identity of the backing query — the action context's filter identity. */ navigationQueryKey: string; /** The page a selection of `item` is stamped with — see the action context's `getItemSelectionPage`. */ getSelectionPage: (item: GalleryItem) => number; + /** Lazily fetch the full ordered listing when an action needs an item beyond the sparse loaded pages. */ + loadOrderedRefs: (signal: AbortSignal) => Promise; selectPreviewItem: (item: GalleryItem) => void; /** How many of `boardItems` lead as the starred strip, ahead of the in-progress sessions. */ stripItemCount: number; @@ -184,79 +209,70 @@ export const usePreviewNavigation = ({ /** The gallery's in-progress tiles, in its order; only running ones can be stepped onto. */ progressSessions: readonly QueueProgressSession[]; queueItems: QueueItem[]; - selectGalleryItem: (item: GalleryItem, selectionPage: number) => void; + selectGalleryItem: (item: GalleryItem, selectionPage: number, absoluteIndex?: number) => void; selectedImageQuery: ReturnType; selectedItem: GalleryItem | null; selectedItemKey: GalleryItemKey | null; /** The gallery's active similarity search, or null for the board listing. */ semanticQuery: GallerySemanticReference | null; }): PreviewNavigationState => { + const accountScope = captureAccountScope(); const selectedImageSearch = useMemo( () => parseDateTokens(selectedImageQuery.searchTerm), [selectedImageQuery.searchTerm] ); - // A ranked filmstrip follows the gallery's current search, board and paging; a listing follows the selection's. - const navigationBoardId = semanticQuery ? galleryBoardId : selectedImageQuery.boardId; + // A ranked filmstrip follows the gallery's current search, board and paging; a listing follows the selection's. A + // selection made outside the Gallery navigates its own board even while the Gallery ranks. + const navigationSemanticQuery = selectedImageQuery.itemBoard ? null : semanticQuery; + const navigationBoardId = navigationSemanticQuery ? galleryBoardId : selectedImageQuery.boardId; const navigationGalleryView = selectedImageQuery.galleryView; const navigationOrderDir = selectedImageQuery.imageOrderDir; // The grid partitions: its listing is unstarred-only, with the starred // items in the strip above it, unless the starred filter is on. const navigationStarredOnly = selectedImageQuery.starredOnly; - const navigationSemanticQuery = semanticQuery; const navigationSemanticKey = gallerySemanticReferenceKey(navigationSemanticQuery); + const navigationPaginationMode = + navigationSemanticQuery === null ? selectedImageQuery.paginationMode : galleryPaginationMode; + const selectedPage = getPreviewSelectedPage({ + galleryPage, + navigationBoardId, + navigationSemanticKey, + selectedImageQuery, + }); // Following live has a cursor too, so the listing loads for the step off it. const hasNavigationContext = selectedItem !== null || followedSessionId !== null; - const navigationContextKey = `${followedSessionId ?? ''}:${selectedItemKey ?? ''}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${selectedImageQuery.paginationMode}:${selectedImageQuery.page}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; - const navigationQueryKey = `${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${selectedImageQuery.paginationMode}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; + const navigationContextKey = `${accountScope.epoch}:${followedSessionId ?? ''}:${selectedItemKey ?? ''}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${navigationPaginationMode}:${selectedPage}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; + const navigationQueryKey = `${accountScope.epoch}:${navigationBoardId}:${navigationGalleryView}:${navigationOrderDir}:${navigationPaginationMode}:${selectedImageQuery.searchTerm}:${navigationStarredOnly}:${navigationSemanticKey}`; + const queryClient = useQueryClient(); // Publish navigation context in layout effect so boundary-fetch continuations cannot observe new UI with a stale // fence. const navigationContextKeyRef = useRef(navigationContextKey); + const navigationQueryKeyRef = useRef(navigationQueryKey); + const pendingNavigationContextRef = useRef(null); + const pageFetchRequestRef = useRef<{ contextKey: string; controller: AbortController } | null>(null); useLayoutEffect(() => { navigationContextKeyRef.current = navigationContextKey; - }, [navigationContextKey]); + navigationQueryKeyRef.current = navigationQueryKey; + const pageFetchRequest = pageFetchRequestRef.current; - // Keep paginated navigation anchored until query identity changes; expose the dependency as derived state. - const [navigationAnchor, setNavigationAnchor] = useState({ - page: selectedImageQuery.page, - queryKey: navigationQueryKey, - }); - const hasStaleNavigationAnchor = navigationAnchor.queryKey !== navigationQueryKey; + if (pageFetchRequest && pageFetchRequest.contextKey !== navigationContextKey) { + pageFetchRequest.controller.abort(); + pageFetchRequestRef.current = null; + } - if (hasStaleNavigationAnchor) { - setNavigationAnchor({ page: selectedImageQuery.page, queryKey: navigationQueryKey }); - } + if (pendingNavigationContextRef.current !== navigationContextKey) { + pendingNavigationContextRef.current = null; + } + }, [navigationContextKey, navigationQueryKey]); - // Only paginated anchors are sticky. Infinite steps preserve their anchor, while external selections must - // immediately reanchor the window. - const navigationAnchorPage = - selectedImageQuery.paginationMode === 'paginated' - ? hasStaleNavigationAnchor - ? selectedImageQuery.page - : navigationAnchor.page - : selectedImageQuery.page; - const isPaginatedWindow = selectedImageQuery.paginationMode === 'paginated'; - // Anchor deep navigation at the selection page. Shared infinite windows cannot grow upward without shifting grid - // content, so retain that anchor and derive mid-board exclusions from it. - const deepAnchorOffset = - !isPaginatedWindow && - navigationSemanticQuery === null && - navigationAnchorPage * GALLERY_PAGE_SIZE >= GALLERY_MAX_ROWS - ? navigationAnchorPage * GALLERY_PAGE_SIZE - : 0; - - // Rankings follow grid paging, not stamped board pages, which can address unrelated or empty ranking slices. - const navigationWindow = - navigationSemanticQuery !== null - ? galleryPaginationMode === 'paginated' - ? ({ kind: 'anchor', offset: galleryPage * GALLERY_PAGE_SIZE } as const) - : // In infinite mode the grid's page IS its window offset, so mirroring - // it covers the deep-reveal case below without a separate test. - ({ kind: 'infinite', offset: galleryPage * GALLERY_PAGE_SIZE } as const) - : selectedImageQuery.paginationMode === 'paginated' - ? ({ kind: 'anchor', offset: navigationAnchorPage * GALLERY_PAGE_SIZE } as const) - : ({ kind: 'infinite', offset: deepAnchorOffset } as const); + useMountEffect(() => () => pageFetchRequestRef.current?.controller.abort()); + + const isPaginatedWindow = navigationPaginationMode === 'paginated'; + const selectedPageOffset = Math.max(0, selectedPage) * GALLERY_PAGE_SIZE; + const isDeepPageWindow = + !isPaginatedWindow && navigationSemanticQuery === null && selectedPageOffset >= GALLERY_MAX_ROWS; const listingFilter = useMemo( (): GalleryItemsFilter => ({ @@ -271,23 +287,53 @@ export const usePreviewNavigation = ({ [navigationBoardId, navigationGalleryView, navigationOrderDir, navigationSemanticQuery, selectedImageSearch] ); - const { - data: boardItemsData, - fetchNextPage: fetchNextBoardItemsPage, - fetchPreviousPage: fetchPreviousBoardItemsPage, - hasNextPage: hasNextBoardItemsPage, - hasPreviousPage: hasPreviousBoardItemsPage, - isFetching: isFetchingBoardItems, - isFetchingNextPage: isFetchingNextBoardItemsPage, - isFetchingPreviousPage: isFetchingPreviousBoardItemsPage, - } = useInfiniteQuery({ - ...galleryItemsInfiniteOptions({ ...listingFilter, starred: navigationStarredOnly }, navigationWindow), + const listingFilterWithStarred = useMemo( + () => ({ ...listingFilter, starred: navigationStarredOnly }), + [listingFilter, navigationStarredOnly] + ); + const selectedPageQuery = useQuery({ + ...galleryItemsPageOptions(listingFilterWithStarred, selectedPageOffset), enabled: hasNavigationContext, }); + const adjacentPageOffsets = useMemo(() => { + const offsets: number[] = []; + + if (selectedPageOffset >= GALLERY_PAGE_SIZE) { + offsets.push(selectedPageOffset - GALLERY_PAGE_SIZE); + } + + if (selectedPageQuery.data === undefined || selectedPageOffset + GALLERY_PAGE_SIZE < selectedPageQuery.data.total) { + offsets.push(selectedPageOffset + GALLERY_PAGE_SIZE); + } + + return offsets; + }, [selectedPageOffset, selectedPageQuery.data]); + const adjacentPageQueries = useQueries({ + queries: adjacentPageOffsets.map((offset) => ({ + ...galleryItemsPageOptions(listingFilterWithStarred, offset), + enabled: hasNavigationContext, + })), + }); + const boardPageResults = useMemo( + () => + [ + { offset: selectedPageOffset, data: selectedPageQuery.data }, + ...adjacentPageOffsets.map((offset, index) => ({ + offset, + data: adjacentPageQueries[index]?.data, + })), + ] + .filter((page): page is { offset: number; data: GalleryItemsPage } => page.data !== undefined) + .sort((a, b) => a.offset - b.offset), + [adjacentPageOffsets, adjacentPageQueries, selectedPageOffset, selectedPageQuery.data] + ); + const backendBoardItems = useMemo(() => boardPageResults.flatMap(({ data }) => data.items), [boardPageResults]); + const listingTotal = selectedPageQuery.data?.total ?? boardPageResults[0]?.data.total; + const isFetchingBoardItems = selectedPageQuery.isFetching || adjacentPageQueries.some((query) => query.isFetching); - // Share Gallery's bounded starred strip except for ranked, starred-only, or mid-board windows. + // Share Gallery's bounded starred strip except for ranked, starred-only, or deep windows. const hasStrip = - hasNavigationContext && !navigationStarredOnly && navigationSemanticQuery === null && deepAnchorOffset === 0; + hasNavigationContext && !navigationStarredOnly && navigationSemanticQuery === null && !isDeepPageWindow; const { data: stripData } = useQuery({ ...galleryStarredStripOptions(listingFilter), enabled: hasStrip }); const stripItems = useMemo(() => { if (!hasStrip) { @@ -305,37 +351,57 @@ export const usePreviewNavigation = ({ }, [hasStrip, selectedItem, selectedItemKey, stripData]); const getSelectionPageIn = useCallback( - (item: GalleryItem, data: typeof boardItemsData): number => { + (item: GalleryItem, pages: typeof boardPageResults): number => { const itemKey = toGalleryItemKey(item); - const pageIndex = data?.pages.findIndex((page) => - page.items.some((candidate) => toGalleryItemKey(candidate) === itemKey) - ); - const pageParam = pageIndex === undefined || pageIndex < 0 ? undefined : data?.pageParams[pageIndex]; - // Stamp ranked picks with board page zero, never ranking offsets. Infinite picks retain their window anchor - // to preserve prior pages; items outside the window use the top-of-board context. - return navigationSemanticQuery !== null - ? 0 - : selectedImageQuery.paginationMode === 'paginated' - ? typeof pageParam === 'number' - ? Math.floor(pageParam / GALLERY_PAGE_SIZE) - : selectedImageQuery.page - : typeof pageParam === 'number' - ? deepAnchorOffset / GALLERY_PAGE_SIZE - : 0; + const page = pages.find(({ data }) => data.items.some((candidate) => toGalleryItemKey(candidate) === itemKey)); + + // Ranked picks retain their exact result page. Ordinary picks use their exact shared page; local anchors fall + // back to the selected page in paginated mode or the board top for recent items outside this page set. + if (navigationSemanticQuery !== null) { + return page === undefined ? selectedPage : page.offset / GALLERY_PAGE_SIZE; + } + + return page === undefined + ? isPaginatedWindow && !isDeepPageWindow + ? selectedPage + : 0 + : page.offset / GALLERY_PAGE_SIZE; }, - [deepAnchorOffset, navigationSemanticQuery, selectedImageQuery.page, selectedImageQuery.paginationMode] + [isDeepPageWindow, isPaginatedWindow, navigationSemanticQuery, selectedPage] ); const stampSelection = useCallback( - (item: GalleryItem, data: typeof boardItemsData) => selectGalleryItem(item, getSelectionPageIn(item, data)), - [getSelectionPageIn, selectGalleryItem] + (item: GalleryItem, pages: typeof boardPageResults) => { + if (isAccountScopeCurrent(accountScope)) { + const page = pages.find(({ data }) => + data.items.some((candidate) => toGalleryItemKey(candidate) === toGalleryItemKey(item)) + ); + const itemIndex = page?.data.items.findIndex( + (candidate) => toGalleryItemKey(candidate) === toGalleryItemKey(item) + ); + const pageItemIndex = itemIndex !== undefined && itemIndex >= 0 ? itemIndex : undefined; + const candidateAbsoluteIndex = + page && pageItemIndex !== undefined + ? (page.data.itemIndices?.[pageItemIndex] ?? page.offset + pageItemIndex) + : undefined; + const absoluteIndex = + candidateAbsoluteIndex !== undefined && + Number.isInteger(candidateAbsoluteIndex) && + candidateAbsoluteIndex >= 0 + ? candidateAbsoluteIndex + : undefined; + + selectGalleryItem(item, getSelectionPageIn(item, pages), absoluteIndex); + } + }, + [accountScope, getSelectionPageIn, selectGalleryItem] ); const getSelectionPage = useCallback( - (item: GalleryItem) => getSelectionPageIn(item, boardItemsData), - [boardItemsData, getSelectionPageIn] + (item: GalleryItem) => getSelectionPageIn(item, boardPageResults), + [boardPageResults, getSelectionPageIn] ); const selectPreviewItem = useCallback( - (item: GalleryItem) => stampSelection(item, boardItemsData), - [boardItemsData, stampSelection] + (item: GalleryItem) => stampSelection(item, boardPageResults), + [boardPageResults, stampSelection] ); const optimisticQueueItemIds = useMemo( @@ -346,12 +412,12 @@ export const usePreviewNavigation = ({ [queueItems] ); const navigationLocalItems = useMemo(() => { - // Keep recent results until listings catch up, except in filtered or mid-board windows where they do not + // Keep recent results until listings catch up, except in filtered or deep windows where they do not // belong. Those windows merge only in-flight work and selection. const hasActiveSearch = navigationStarredOnly || selectedImageSearch.text.trim() !== '' || selectedImageSearch.range !== undefined; - if (!hasActiveSearch && !isPaginatedWindow && deepAnchorOffset === 0) { + if (!hasActiveSearch && !isPaginatedWindow && !isDeepPageWindow) { return localItems; } @@ -364,7 +430,7 @@ export const usePreviewNavigation = ({ item.sourceQueueItemId === refreshingSelectedSourceId ); }, [ - deepAnchorOffset, + isDeepPageWindow, isFetchingBoardItems, isPaginatedWindow, localItems, @@ -397,7 +463,6 @@ export const usePreviewNavigation = ({ return [selectedItem, ...listingLocalItems]; }, [hasStrip, localBoardItems, selectedItem, selectedItemKey]); - const backendBoardItems = useMemo(() => flattenPreviewItems(boardItemsData), [boardItemsData]); // Rankings accept only selection as cursor anchor, never board recents. const previewMergeItems = useMemo( () => @@ -422,6 +487,41 @@ export const usePreviewNavigation = ({ () => (stripItems.length === 0 ? listingItems : [...stripItems, ...listingItems]), [listingItems, stripItems] ); + const loadOrderedRefs = useCallback( + async (signal: AbortSignal): Promise => { + const requestSignal = AbortSignal.any([signal, accountScope.signal]); + requestSignal.throwIfAborted(); + const namesPromise = queryClient.fetchQuery(galleryItemNamesOptions(listingFilterWithStarred)); + let abortListener: (() => void) | undefined; + + try { + const names = await Promise.race([ + namesPromise, + new Promise((_resolve, reject) => { + abortListener = () => + reject(requestSignal.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + requestSignal.addEventListener('abort', abortListener, { once: true }); + if (requestSignal.aborted) { + abortListener(); + } + }), + ]); + + requestSignal.throwIfAborted(); + // Fenced on the listing, not the selection: a deletion clears the selection before it asks for this order. + if (!isAccountScopeCurrent(accountScope) || navigationQueryKeyRef.current !== navigationQueryKey) { + throw new DOMException('The Preview listing changed.', 'AbortError'); + } + + return [...stripItems.map(toGalleryItemRef), ...names.items]; + } finally { + if (abortListener) { + requestSignal.removeEventListener('abort', abortListener); + } + } + }, + [accountScope, listingFilterWithStarred, navigationQueryKey, queryClient, stripItems] + ); const isLoadingBoard = hasNavigationContext && isFetchingBoardItems; const sessionEntries = useMemo( (): GalleryNavigationEntry[] => @@ -443,84 +543,377 @@ export const usePreviewNavigation = ({ followedSessionId !== null || selectedItemKey === null ? -1 : boardItems.findIndex((item) => toGalleryItemKey(item) === selectedItemKey); + const selectedItemPageOffset = useMemo(() => { + if (selectedItemKey === null) { + return selectedPageOffset; + } - // Preview is the only surface that walks a paginated listing across its pages, so at a loaded edge the next page - // wins over the strip seam; the strip is reached from the listing's first page. - const isAtLoadedBackendBoundary = useCallback( - (offset: -1 | 1): boolean => - followedSessionId === null && - selectedItemKey !== null && - (offset === 1 - ? backendBoardItems.at(-1) !== undefined && - toGalleryItemKey(backendBoardItems.at(-1)!) === selectedItemKey && - hasNextBoardItemsPage - : backendBoardItems[0] !== undefined && - toGalleryItemKey(backendBoardItems[0]) === selectedItemKey && - hasPreviousBoardItemsPage), - [backendBoardItems, followedSessionId, hasNextBoardItemsPage, hasPreviousBoardItemsPage, selectedItemKey] + return ( + boardPageResults.find(({ data }) => data.items.some((item) => toGalleryItemKey(item) === selectedItemKey)) + ?.offset ?? selectedPageOffset + ); + }, [boardPageResults, selectedItemKey, selectedPageOffset]); + const selectedListingPage = boardPageResults.find(({ data }) => + data.items.some((item) => toGalleryItemKey(item) === selectedItemKey) + ); + const selectedListingPageItems = selectedListingPage?.data.items ?? EMPTY_PREVIEW_ITEMS; + const isSelectedListingItem = (item: GalleryItem | undefined): boolean => + item !== undefined && toGalleryItemKey(item) === selectedItemKey; + const selectedItemStartsPage = isSelectedListingItem(selectedListingPageItems[0]); + const selectedItemEndsPage = isSelectedListingItem(selectedListingPageItems.at(-1)); + // A recent local item or another section can sit just past a page edge; while the neighboring page is missing, + // loading, or failed, a loaded step would skip that page's items. + const isListingPageUnresolved = (pageOffset: number): boolean => { + if (pageOffset < 0 || (listingTotal !== undefined && pageOffset >= listingTotal)) { + return false; + } + + const pageQuery = + pageOffset === selectedPageOffset + ? selectedPageQuery + : adjacentPageQueries[adjacentPageOffsets.indexOf(pageOffset)]; + + return ( + !boardPageResults.some(({ offset }) => offset === pageOffset) || + pageQuery?.isFetching === true || + pageQuery?.isError === true + ); + }; + const hasUnresolvedPreviousListingPage = + selectedItemStartsPage && isListingPageUnresolved(selectedItemPageOffset - GALLERY_PAGE_SIZE); + const hasUnresolvedNextListingPage = + selectedItemEndsPage && isListingPageUnresolved(selectedItemPageOffset + GALLERY_PAGE_SIZE); + const selectedItemIsMissingFromStampedPage = + selectedItem !== null && + selectedItemKey !== null && + navigationSemanticQuery === null && + !isDateBoardId(navigationBoardId) && + selectedPageQuery.data !== undefined && + !selectedPageQuery.isFetching && + !stripItems.some((item) => toGalleryItemKey(item) === selectedItemKey) && + !navigationLocalItems.some((item) => toGalleryItemKey(item) === selectedItemKey) && + !boardPageResults + .find(({ offset }) => offset === selectedPageOffset) + ?.data.items.some((item) => toGalleryItemKey(item) === selectedItemKey); + const selectedItemNeedsLocation = selectedItemIsMissingFromStampedPage; + // `boardItems` holds only the pages around the selection; the listing part of the cursor is offset by where that + // window starts, and the total adds the listing items outside it. A selection not yet located in its listing sits + // in the window only as an anchor, so it has no position until a step locates it. + const windowStartIndex = boardPageResults[0]?.offset ?? 0; + const isSelectionInLoadedPage = boardPageResults.some(({ data }) => + data.items.some((item) => toGalleryItemKey(item) === selectedItemKey) + ); + const positionIndex = + selectedItemNeedsLocation && !isSelectionInLoadedPage + ? -1 + : navigationCursor < stripItems.length + ? navigationCursor + : navigationCursor + windowStartIndex; + const positionTotal = + selectedItemNeedsLocation && !isSelectionInLoadedPage + ? stripItems.length + (listingTotal ?? backendBoardItems.length) + : Math.max(positionIndex + 1, boardItems.length + Math.max(0, (listingTotal ?? 0) - backendBoardItems.length)); + const position = useMemo(() => ({ index: positionIndex, total: positionTotal }), [positionIndex, positionTotal]); + const getSectionsFor = useCallback( + (pages: typeof boardPageResults) => [ + stripEntries, + sessionEntries, + toItemEntries(mergeListingItems(pages.flatMap(({ data }) => data.items))), + ], + [mergeListingItems, sessionEntries, stripEntries] + ); + // A step right onto the listing comes from the strip or the sessions, and enters it where the grid's listing starts: + // the board's top, or the page's in paginated mode. A window anchored lower would otherwise land mid-board. Returns + // the pages to step within, 'unloaded' while the start page is not loaded, or null for an ordinary step. + const listingStartOffset = isPaginatedWindow ? selectedPageOffset : 0; + const firstListingItem = listingItems[0]; + const getListingStartPages = useCallback( + (entry: GalleryNavigationEntry | null): typeof boardPageResults | 'unloaded' | null => { + if (entry?.kind !== 'item' || entry.item !== firstListingItem) { + return null; + } + + const entryKey = toGalleryItemKey(entry.item); + const entryPage = boardPageResults.find(({ data }) => + data.items.some((item) => toGalleryItemKey(item) === entryKey) + ); + + if (entryPage === undefined || entryPage.offset === listingStartOffset) { + return null; + } + + const startPages = boardPageResults.filter(({ offset }) => offset >= listingStartOffset); + + return startPages[0]?.offset === listingStartOffset ? startPages : 'unloaded'; + }, + [boardPageResults, firstListingItem, listingStartOffset] ); // Share navigation between keyboard, footer, and swipe; comparison does not step saved images. const navigate = useCallback( (offset: -1 | 1): Promise => { - if (isComparing) { + if (isComparing || !isAccountScopeCurrent(accountScope)) { return Promise.resolve(false); } const direction = offset === 1 ? 'right' : 'left'; - const stepTo = (entry: GalleryNavigationEntry | null, data: typeof boardItemsData): boolean => { + const isCurrentNavigation = (): boolean => + isAccountScopeCurrent(accountScope) && navigationContextKeyRef.current === navigationContextKey; + const stepTo = (entry: GalleryNavigationEntry | null, pages: typeof boardPageResults): boolean => { if (entry?.kind === 'session') { followSession(entry.id); - } else if (entry) { - stampSelection(entry.item, data); + } else if (entry?.kind === 'item') { + stampSelection(entry.item, pages); + } else { + return false; } - return entry !== null; + return true; }; - if (!isAtLoadedBackendBoundary(offset)) { + const loadedEntry = getGalleryNavigationStep(navigationSections, cursorKeys, direction); + const listingStartPages = offset === 1 ? getListingStartPages(loadedEntry) : null; + + if (Array.isArray(listingStartPages)) { + pageFetchRequestRef.current?.controller.abort(); + pageFetchRequestRef.current = null; + pendingNavigationContextRef.current = null; return Promise.resolve( - stepTo(getGalleryNavigationStep(navigationSections, cursorKeys, direction), boardItemsData) + stepTo(getGalleryNavigationStep(getSectionsFor(listingStartPages), cursorKeys, direction), listingStartPages) ); } - if (offset === 1 ? isFetchingNextBoardItemsPage : isFetchingPreviousBoardItemsPage) { + if ( + listingStartPages === null && + loadedEntry !== null && + !selectedItemNeedsLocation && + !(offset === 1 ? hasUnresolvedNextListingPage : hasUnresolvedPreviousListingPage) + ) { + pageFetchRequestRef.current?.controller.abort(); + pageFetchRequestRef.current = null; + pendingNavigationContextRef.current = null; + return Promise.resolve(stepTo(loadedEntry, boardPageResults)); + } + + if (pendingNavigationContextRef.current === navigationContextKey) { return Promise.resolve(false); } - const fetchBoundaryPage = offset === 1 ? fetchNextBoardItemsPage : fetchPreviousBoardItemsPage; + pendingNavigationContextRef.current = navigationContextKey; + const controller = new AbortController(); + const requestSignal = AbortSignal.any([controller.signal, accountScope.signal]); + pageFetchRequestRef.current = { contextKey: navigationContextKey, controller }; + + const pages = [...boardPageResults]; + let pageOffset = selectedItemPageOffset; + let total = listingTotal ?? pages[0]?.data.total; + const updatePage = (offset: number, data: GalleryItemsPage) => { + const index = pages.findIndex((page) => page.offset === offset); + const page = { offset, data }; + + if (index === -1) { + pages.push(page); + } else { + pages[index] = page; + } - return fetchBoundaryPage().then((result) => { - if (result.isError || navigationContextKeyRef.current !== navigationContextKey) { + pages.sort((a, b) => a.offset - b.offset); + }; + + return (async () => { + try { + if (listingStartPages === 'unloaded') { + const startPage = await fetchGalleryItemsPage(queryClient, listingFilterWithStarred, listingStartOffset, { + signal: requestSignal, + }); + + if (!isCurrentNavigation()) { + return false; + } + + const startPages = [{ offset: listingStartOffset, data: startPage }]; + + return stepTo(getGalleryNavigationStep(getSectionsFor(startPages), cursorKeys, direction), startPages); + } + + if (selectedItemNeedsLocation && selectedItem) { + const located = await fetchVerifiedGalleryItemPage( + queryClient, + listingFilterWithStarred, + toGalleryItemRef(selectedItem), + accountScope, + requestSignal + ); + + if (!isCurrentNavigation()) { + return false; + } + + if (located !== null) { + pages.splice(0, pages.length, { offset: located.offset, data: located.page }); + pageOffset = located.offset; + total = located.total; + + // At the located page's edge, its neighbor page comes first: a recent or another section could + // otherwise sit just past the edge and take the step. + const adjacentOffset = + direction === 'left' + ? located.index === located.offset && located.offset > 0 + ? located.offset - GALLERY_PAGE_SIZE + : null + : located.page.items.at(-1) !== undefined && + toGalleryItemKey(located.page.items.at(-1)!) === toGalleryItemKey(selectedItem) && + located.offset + GALLERY_PAGE_SIZE < located.total + ? located.offset + GALLERY_PAGE_SIZE + : null; + + if (adjacentOffset !== null) { + const adjacentPage = await fetchGalleryItemsPage( + queryClient, + listingFilterWithStarred, + adjacentOffset, + { signal: requestSignal, staleTime: 0 } + ); + + if (!isCurrentNavigation()) { + return false; + } + + updatePage(adjacentOffset, adjacentPage); + } + + const sections = [ + stripEntries, + sessionEntries, + toItemEntries( + mergePreviewBoardItems( + pages.flatMap(({ data }) => data.items), + previewMergeItems, + navigationOrderDir, + { isRanked: navigationSemanticQuery !== null } + ).filter((item) => !stripKeys.has(toGalleryItemKey(item))) + ), + ]; + const entry = getGalleryNavigationStep(sections, cursorKeys, direction); + + if (entry !== null) { + return stepTo(entry, pages); + } + } else if (selectedPageOffset >= (listingTotal ?? total ?? 0)) { + return false; + } else if (loadedEntry !== null) { + return stepTo(loadedEntry, boardPageResults); + } + } + + if (total === undefined) { + const currentPage = await fetchGalleryItemsPage(queryClient, listingFilterWithStarred, selectedPageOffset, { + signal: requestSignal, + }); + + if (!isCurrentNavigation()) { + return false; + } + + updatePage(selectedPageOffset, currentPage); + total = currentPage.total; + + const currentSections = [ + stripEntries, + sessionEntries, + toItemEntries( + mergePreviewBoardItems( + pages.flatMap(({ data }) => data.items), + previewMergeItems, + navigationOrderDir, + { isRanked: navigationSemanticQuery !== null } + ).filter((item) => !stripKeys.has(toGalleryItemKey(item))) + ), + ]; + const currentEntry = getGalleryNavigationStep(currentSections, cursorKeys, direction); + + if (currentEntry !== null) { + return stepTo(currentEntry, pages); + } + } + + while (true) { + pageOffset += offset * GALLERY_PAGE_SIZE; + + if (pageOffset < 0 || pageOffset >= (total ?? 0) || !isCurrentNavigation()) { + return false; + } + + try { + // Query deduplicates with the adjacent observer if it is already loading this page. + const page = await fetchGalleryItemsPage(queryClient, listingFilterWithStarred, pageOffset, { + signal: requestSignal, + }); + + if (!isCurrentNavigation()) { + return false; + } + + updatePage(pageOffset, page); + total = page.total; + const pageItems = pages.flatMap(({ data }) => data.items); + const sections = [ + stripEntries, + sessionEntries, + toItemEntries( + mergePreviewBoardItems(pageItems, previewMergeItems, navigationOrderDir, { + isRanked: navigationSemanticQuery !== null, + }).filter((item) => !stripKeys.has(toGalleryItemKey(item))) + ), + ]; + const entry = getGalleryNavigationStep(sections, cursorKeys, direction); + + if (entry !== null) { + return stepTo(entry, pages); + } + } catch { + return false; + } + } + } catch { return false; + } finally { + if (pageFetchRequestRef.current?.controller === controller) { + pageFetchRequestRef.current = null; + if (pendingNavigationContextRef.current === navigationContextKey) { + pendingNavigationContextRef.current = null; + } + } } - - // Against the data just fetched: the item is not in the pages this - // render closed over, and a lookup there would read it as an item - // the window does not hold. - const nextSections = [ - stripEntries, - sessionEntries, - toItemEntries(mergeListingItems(flattenPreviewItems(result.data))), - ]; - - return stepTo(getGalleryNavigationStep(nextSections, cursorKeys, direction), result.data); - }); + })(); }, [ - boardItemsData, + boardPageResults, cursorKeys, - fetchNextBoardItemsPage, - fetchPreviousBoardItemsPage, followSession, - isAtLoadedBackendBoundary, + getListingStartPages, + getSectionsFor, isComparing, - isFetchingNextBoardItemsPage, - isFetchingPreviousBoardItemsPage, - mergeListingItems, + listingStartOffset, + accountScope, + listingFilterWithStarred, + listingTotal, navigationContextKey, navigationSections, + navigationOrderDir, + selectedPageOffset, + selectedItem, + selectedItemNeedsLocation, + hasUnresolvedNextListingPage, + hasUnresolvedPreviousListingPage, + navigationSemanticQuery, + pendingNavigationContextRef, + pageFetchRequestRef, + previewMergeItems, + queryClient, + selectedItemPageOffset, sessionEntries, + stripKeys, stampSelection, stripEntries, ] @@ -532,13 +925,46 @@ export const usePreviewNavigation = ({ return NO_NEIGHBORS; } - const resolve = (offset: -1 | 1): PreviewNeighbor => - isAtLoadedBackendBoundary(offset) - ? { kind: 'more' } - : toNeighbor(getGalleryNavigationStep(navigationSections, cursorKeys, offset === 1 ? 'right' : 'left')); + const resolve = (offset: -1 | 1): PreviewNeighbor => { + const neighbor = getGalleryNavigationStep(navigationSections, cursorKeys, offset === 1 ? 'right' : 'left'); + const listingStartPages = offset === 1 ? getListingStartPages(neighbor) : null; + + if (listingStartPages !== null) { + return listingStartPages === 'unloaded' + ? { kind: 'more' } + : toNeighbor(getGalleryNavigationStep(getSectionsFor(listingStartPages), cursorKeys, 'right')); + } + + // A selection that must be relocated first steps from wherever it is found, not from the loaded guess. + if ( + selectedItemNeedsLocation || + (offset === 1 ? hasUnresolvedNextListingPage : hasUnresolvedPreviousListingPage) + ) { + return { kind: 'more' }; + } + + if (neighbor !== null) { + return toNeighbor(neighbor); + } + + const targetOffset = selectedItemPageOffset + offset * GALLERY_PAGE_SIZE; + + return targetOffset >= 0 && (listingTotal === undefined || targetOffset < listingTotal) ? { kind: 'more' } : null; + }; return { next: resolve(1), previous: resolve(-1) }; - }, [cursorKeys, isAtLoadedBackendBoundary, isComparing, navigationSections]); + }, [ + cursorKeys, + getListingStartPages, + getSectionsFor, + hasUnresolvedNextListingPage, + hasUnresolvedPreviousListingPage, + isComparing, + listingTotal, + navigationSections, + selectedItemNeedsLocation, + selectedItemPageOffset, + ]); // Prefetch the images a step would land on to avoid decode flashes during navigation. const previousNeighborUrl = @@ -561,9 +987,10 @@ export const usePreviewNavigation = ({ isLoadingBoard, navigate, neighbors, - navigationCursor, navigationQueryKey, getSelectionPage, + position, + loadOrderedRefs, selectPreviewItem, stripItemCount: stripItems.length, }; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 885468d2ace..c9db763eea2 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -5250,7 +5250,7 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(getProjectWidgetValues(getActiveProject(state), 'gallery').liveFollowPausedAt).toBeUndefined(); }); - it('stamps an explicit page into the navigation query already on a multi-selection', () => { + it('stamps a host page into the navigation query already on a multi-selection', () => { // Host navigation uses the selection's query and page, which may differ from the gallery's current // board/search. let state = createInitialWorkbenchState(); @@ -5266,6 +5266,7 @@ describe('workbenchReducer Phase 5 generation flow', () => { state = workbenchReducer(state, { searchTerm: 'sunset', type: 'setGallerySearchTerm' }); state = workbenchReducer(state, { itemKeys: ['image:failed.png', 'image:successor.png'], + preserveNavigationQuery: true, primaryItem: createGalleryImageItem('successor.png'), selectionPage: 30, type: 'setGalleryMultiSelection', @@ -5281,6 +5282,165 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(values.galleryPage).toBe(0); }); + it('stamps a Gallery range with the listing the grid shows now', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { item: createGalleryImageItem('anchor.png'), type: 'selectGalleryItem' }); + state = workbenchReducer(state, { settings: { imageOrderDir: 'ASC' }, type: 'updateGallerySettings' }); + state = workbenchReducer(state, { starredOnly: true, type: 'setGalleryStarredOnly' }); + state = workbenchReducer(state, { searchTerm: 'sunset', type: 'setGallerySearchTerm' }); + state = workbenchReducer(state, { + itemKeys: ['image:anchor.png', 'image:range-end.png'], + primaryItem: createGalleryImageItem('range-end.png'), + selectionPage: 2, + type: 'setGalleryMultiSelection', + }); + + const values = getProjectWidgetValues(getActiveProject(state), 'gallery'); + + expect(values.selectedImagePage).toBe(2); + expect(values.selectedImageQuery).toMatchObject({ + imageOrderDir: 'ASC', + page: 2, + searchTerm: 'sunset', + starredOnly: true, + }); + }); + + it('stamps a toggled selection with the page of the item that becomes primary', () => { + const stamp = () => { + const values = getProjectWidgetValues(getActiveProject(state), 'gallery'); + + return { page: values.selectedImagePage, query: values.selectedImageQuery }; + }; + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { semanticImageQuery: { kind: 'text', query: 'sunset' } }, + widgetId: 'gallery', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked-3.png'), + selectionPage: 0, + type: 'selectGalleryItem', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked-125.png'), + nextPrimaryItem: null, + selectionPage: 2, + type: 'toggleGalleryItemInSelection', + }); + expect(stamp()).toMatchObject({ page: 2, query: { page: 2, semanticKey: 'text:sunset' } }); + + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked-70.png'), + nextPrimaryItem: null, + selectionPage: 1, + type: 'toggleGalleryItemInSelection', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked-70.png'), + nextPrimaryItem: createGalleryImageItem('ranked-125.png'), + selectionPage: 2, + type: 'toggleGalleryItemInSelection', + }); + expect(stamp()).toMatchObject({ page: 2, query: { page: 2, semanticKey: 'text:sunset' } }); + + // Without a known page the next primary keeps the previous stamp. + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked-125.png'), + nextPrimaryItem: createGalleryImageItem('ranked-3.png'), + type: 'toggleGalleryItemInSelection', + }); + expect(stamp()).toMatchObject({ page: 2, query: { page: 2 } }); + }); + + it.each(['selectGalleryItem', 'setGalleryMultiSelection'] as const)( + 'stamps a ranked %s step with the board the grid ranks within, its project board when none is saved', + (type) => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { projectBoardId: 'board-project', selectedBoardId: undefined }, + widgetId: 'gallery', + }); + state = workbenchReducer(state, { item: createGalleryImageItem('first.png'), type: 'selectGalleryItem' }); + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { semanticImageQuery: { kind: 'text', query: 'sunset' } }, + widgetId: 'gallery', + }); + const ranked = createGalleryImageItem('ranked.png'); + state = workbenchReducer( + state, + type === 'selectGalleryItem' + ? { item: ranked, preserveNavigationQuery: true, selectionPage: 1, type } + : { + itemKeys: ['image:ranked.png'], + preserveNavigationQuery: true, + primaryItem: ranked, + selectionPage: 1, + type, + } + ); + + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageQuery).toMatchObject({ + boardId: 'board-project', + page: 1, + semanticKey: 'text:sunset', + }); + } + ); + + it('ties a selected semantic result page to the active ranking', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { semanticImageQuery: { kind: 'text', query: 'sunset' } }, + widgetId: 'gallery', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked.png'), + selectionPage: 3, + type: 'selectGalleryItem', + }); + + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageQuery).toMatchObject({ + page: 3, + semanticKey: 'text:sunset', + }); + }); + + it('retires a semantic page stamp when Preview resumes ordinary listing navigation', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { semanticImageQuery: { kind: 'text', query: 'sunset' } }, + widgetId: 'gallery', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ranked.png'), + selectionPage: 3, + type: 'selectGalleryItem', + }); + state = workbenchReducer(state, { type: 'clearGallerySearch' }); + state = workbenchReducer(state, { + item: createGalleryImageItem('ordinary.png'), + preserveNavigationQuery: true, + selectionPage: 1, + type: 'selectGalleryItem', + }); + + expect(getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageQuery).toMatchObject({ + page: 1, + semanticKey: null, + }); + }); + it('pauses live-follow for saved Gallery multi-selection and comparison intents', () => { const primaryImage = createGalleryImageItem('primary.png'); const compareImage = createGalleryImageItem('compare.png'); @@ -5329,6 +5489,58 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(values.selectedImageQuery).toMatchObject({ starredOnly: false }); }); + it('stamps a selection made outside the Gallery with the board the item belongs to, unfiltered', () => { + let state = createInitialWorkbenchState(); + + // The Gallery shows another board, searched, starred-only and paged. + state = workbenchReducer(state, { boardId: 'board-elsewhere', type: 'selectGalleryBoard' }); + state = workbenchReducer(state, { searchTerm: 'sunset', type: 'setGallerySearchTerm' }); + state = workbenchReducer(state, { starredOnly: true, type: 'setGalleryStarredOnly' }); + state = workbenchReducer(state, { page: 4, type: 'setGalleryPage' }); + state = workbenchReducer(state, { + item: createGalleryImageItem('found.png', 'board-found'), + navigateItemBoard: true, + type: 'selectGalleryItem', + }); + + const values = getProjectWidgetValues(getActiveProject(state), 'gallery'); + + expect(values.selectedImagePage).toBe(0); + expect(values.selectedImageQuery).toMatchObject({ + boardId: 'board-found', + galleryView: 'images', + page: 0, + searchTerm: '', + starredOnly: false, + }); + }); + + it('keeps an item-board stamp through a host step while the Gallery ranks', () => { + let state = createInitialWorkbenchState(); + + state = workbenchReducer(state, { + item: createGalleryImageItem('found.png', 'board-found'), + navigateItemBoard: true, + type: 'selectGalleryItem', + }); + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { semanticImageQuery: { kind: 'text', query: 'sunset' } }, + widgetId: 'gallery', + }); + state = workbenchReducer(state, { + item: createGalleryImageItem('next.png', 'board-found'), + preserveNavigationQuery: true, + selectionPage: 2, + type: 'selectGalleryItem', + }); + + const query = getProjectWidgetValues(getActiveProject(state), 'gallery').selectedImageQuery; + + expect(query).toMatchObject({ boardId: 'board-found', itemBoard: true, page: 2 }); + expect(query).not.toHaveProperty('semanticKey', 'text:sunset'); + }); + it('stamps a selection with the project board the grid shows when no board was chosen', () => { let state = createInitialWorkbenchState(); const projectId = getActiveProject(state).id; @@ -6173,7 +6385,21 @@ describe('workbench backend connection recovery', () => { state = workbenchReducer(state, { projectId: project.id, type: 'patchWidgetValues', - values: { compareImage: image, recentImages: [image], selectedImage: image }, + values: { + compareImage: image, + recentImages: [image], + selectedImage: image, + selectedImagePage: 8, + selectedImageQuery: { + boardId: 'board-1', + galleryView: 'images', + imageOrderDir: 'DESC', + page: 8, + paginationMode: 'paginated', + searchTerm: 'sunset', + starredOnly: true, + }, + }, widgetId: 'gallery', }); } @@ -6190,6 +6416,16 @@ describe('workbench backend connection recovery', () => { expect(values.recentImages).toEqual([patchedImage]); expect(values.selectedImage).toEqual(legacyGeneratedImageToGalleryItem(patchedImage)); expect(values.compareImage).toEqual(legacyGeneratedImageToGalleryItem(patchedImage)); + expect(values.selectedImagePage).toBe(0); + expect(values.selectedImageQuery).toEqual({ + boardId: 'board-2', + galleryView: 'images', + imageOrderDir: 'DESC', + page: 0, + paginationMode: 'infinite', + searchTerm: '', + starredOnly: true, + }); } }); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 0a34d585e44..2e860c44ae9 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -441,6 +441,11 @@ type WorkbenchReducerAction = | { type: 'selectGalleryItem'; item: GalleryItem; + /** + * Navigate within the item's own board, unfiltered, instead of the listing the Gallery shows: for selections + * made outside the Gallery, such as a search result from any board. + */ + navigateItemBoard?: boolean; preserveNavigationQuery?: boolean; projectId?: string; selectionPage?: number; @@ -450,13 +455,17 @@ type WorkbenchReducerAction = item: GalleryItem; nextPrimaryItem: GalleryItem | null; projectId?: string; + /** The grid page of whichever item becomes primary: the added item, or `nextPrimaryItem`. */ + selectionPage?: number; } | { type: 'setGalleryMultiSelection'; itemKeys: GalleryItemKey[]; primaryItem: GalleryItem; + /** Keep the navigation query already on the selection, as `selectGalleryItem` does for a host's own window. */ + preserveNavigationQuery?: boolean; projectId?: string; - /** Stamps this page, in the navigation query already on the selection, instead of the grid's. */ + /** Stamps this page instead of the grid's. */ selectionPage?: number; } | { type: 'setGalleryCompareImage'; image: GalleryImageItem | null; projectId?: string } @@ -3681,6 +3690,49 @@ export const createInitialWorkbenchState = (): WorkbenchState => { }; }; +const hasGallerySelectionPage = (selectionPage: number | undefined): selectionPage is number => + typeof selectionPage === 'number' && Number.isFinite(selectionPage); + +/** An explicit page for the item being selected, else the page the grid is on. */ +const getGallerySelectionPage = (values: Record, selectionPage: number | undefined): number => + hasGallerySelectionPage(selectionPage) + ? Math.max(0, Math.floor(selectionPage)) + : typeof values.galleryPage === 'number' && Number.isFinite(values.galleryPage) + ? Math.max(0, Math.floor(values.galleryPage)) + : 0; + +/** + * A host step keeps the selection's query but follows the Gallery's active ranking, except for a selection that + * navigates its item's own board. + */ +const getRankingStampFields = ( + values: Record, + existingNavigationQuery: Record, + semanticKey: string +): Record => + existingNavigationQuery.itemBoard === true + ? {} + : semanticKey + ? { boardId: getGallerySelectionBoardId(values), semanticKey } + : { semanticKey: null }; + +/** The listing the Gallery shows now, which a selection made in it navigates within. */ +const getCurrentGallerySelectionQuery = (values: Record, page: number): Record => { + const settings = getGallerySettings(values); + const semanticKey = gallerySemanticReferenceKey(parseGallerySemanticReference(values.semanticImageQuery)); + + return { + boardId: getGallerySelectionBoardId(values), + galleryView: values.galleryView === 'assets' ? 'assets' : 'images', + imageOrderDir: settings.imageOrderDir, + page, + paginationMode: settings.paginationMode, + searchTerm: typeof values.searchTerm === 'string' ? values.searchTerm : '', + ...(semanticKey ? { semanticKey } : {}), + starredOnly: values.starredOnly === true, + }; +}; + export const __workbenchReducerInternal = ( state: WorkbenchState, action: WorkbenchReducerAction, @@ -4913,29 +4965,35 @@ export const __workbenchReducerInternal = ( return updateGalleryValuesAndPauseLiveFollow( state, (values) => { + // A selection navigated within its own board has no known page there; Preview locates it. const selectedImagePage = - typeof action.selectionPage === 'number' && Number.isFinite(action.selectionPage) - ? Math.max(0, Math.floor(action.selectionPage)) - : typeof values.galleryPage === 'number' && Number.isFinite(values.galleryPage) - ? Math.max(0, Math.floor(values.galleryPage)) - : 0; - const settings = getGallerySettings(values); + action.navigateItemBoard && !hasGallerySelectionPage(action.selectionPage) + ? 0 + : getGallerySelectionPage(values, action.selectionPage); + const semanticKey = gallerySemanticReferenceKey(parseGallerySemanticReference(values.semanticImageQuery)); const existingNavigationQuery = values.selectedImageQuery && typeof values.selectedImageQuery === 'object' ? (values.selectedImageQuery as Record) : null; - const selectedImageQuery = - action.preserveNavigationQuery && existingNavigationQuery - ? { ...existingNavigationQuery, page: selectedImagePage } - : { - boardId: getGallerySelectionBoardId(values), - galleryView: values.galleryView === 'assets' ? 'assets' : 'images', - imageOrderDir: settings.imageOrderDir, + const settings = getGallerySettings(values); + const selectedImageQuery = action.navigateItemBoard + ? { + boardId: action.item.boardId, + galleryView: action.item.category === 'general' ? 'images' : 'assets', + imageOrderDir: settings.imageOrderDir, + itemBoard: true, + page: selectedImagePage, + paginationMode: settings.paginationMode, + searchTerm: '', + starredOnly: false, + } + : action.preserveNavigationQuery && existingNavigationQuery + ? { + ...existingNavigationQuery, + ...getRankingStampFields(values, existingNavigationQuery, semanticKey), page: selectedImagePage, - paginationMode: settings.paginationMode, - searchTerm: typeof values.searchTerm === 'string' ? values.searchTerm : '', - starredOnly: values.starredOnly === true, - }; + } + : getCurrentGallerySelectionQuery(values, selectedImagePage); const itemKey = toGalleryItemKey(action.item); return { @@ -4959,11 +5017,7 @@ export const __workbenchReducerInternal = ( const selectedItemKeys = getPersistedSelectedGalleryItemKeys(values); if (!selectedItemKeys.includes(itemKey)) { - const settings = getGallerySettings(values); - const selectedImagePage = - typeof values.galleryPage === 'number' && Number.isFinite(values.galleryPage) - ? Math.max(0, Math.floor(values.galleryPage)) - : 0; + const selectedImagePage = getGallerySelectionPage(values, action.selectionPage); return { ...values, @@ -4972,15 +5026,7 @@ export const __workbenchReducerInternal = ( selectedImageName: itemKey, selectedImageNames: [...selectedItemKeys, itemKey], selectedImagePage, - selectedImageQuery: { - boardId: getGallerySelectionBoardId(values), - galleryView: values.galleryView === 'assets' ? 'assets' : 'images', - imageOrderDir: settings.imageOrderDir, - page: selectedImagePage, - paginationMode: settings.paginationMode, - searchTerm: typeof values.searchTerm === 'string' ? values.searchTerm : '', - starredOnly: values.starredOnly === true, - }, + selectedImageQuery: getCurrentGallerySelectionQuery(values, selectedImagePage), }; } @@ -5009,6 +5055,11 @@ export const __workbenchReducerInternal = ( ? action.nextPrimaryItem : null; const nextPrimaryKey = nextPrimaryItem ? toGalleryItemKey(nextPrimaryItem) : null; + // The next primary keeps the old primary's stamp unless the grid knows where the next one sits. + const nextPrimaryPage = + nextPrimaryItem && hasGallerySelectionPage(action.selectionPage) + ? getGallerySelectionPage(values, action.selectionPage) + : null; return { ...values, @@ -5016,6 +5067,12 @@ export const __workbenchReducerInternal = ( selectedImage: nextPrimaryItem, selectedImageName: nextPrimaryKey, selectedImageNames: expectedNextPrimaryKey && !nextPrimaryItem ? [] : remainingItemKeys, + ...(nextPrimaryPage === null + ? {} + : { + selectedImagePage: nextPrimaryPage, + selectedImageQuery: getCurrentGallerySelectionQuery(values, nextPrimaryPage), + }), }; }, action.projectId @@ -5025,13 +5082,8 @@ export const __workbenchReducerInternal = ( return updateGalleryValuesAndPauseLiveFollow( state, (values) => { - const settings = getGallerySettings(values); - const hasSelectionPage = typeof action.selectionPage === 'number' && Number.isFinite(action.selectionPage); - const selectedImagePage = hasSelectionPage - ? Math.max(0, Math.floor(action.selectionPage as number)) - : typeof values.galleryPage === 'number' && Number.isFinite(values.galleryPage) - ? Math.max(0, Math.floor(values.galleryPage)) - : 0; + const semanticKey = gallerySemanticReferenceKey(parseGallerySemanticReference(values.semanticImageQuery)); + const selectedImagePage = getGallerySelectionPage(values, action.selectionPage); const existingNavigationQuery = values.selectedImageQuery && typeof values.selectedImageQuery === 'object' ? (values.selectedImageQuery as Record) @@ -5044,19 +5096,15 @@ export const __workbenchReducerInternal = ( selectedImageName: toGalleryItemKey(action.primaryItem), selectedImageNames: action.itemKeys, selectedImagePage, - // An explicit host page belongs to the selection's query, matching preserveNavigationQuery. + // A Gallery range captures the current listing; only a host stepping within its own window keeps the old one. selectedImageQuery: - hasSelectionPage && existingNavigationQuery - ? { ...existingNavigationQuery, page: selectedImagePage } - : { - boardId: getGallerySelectionBoardId(values), - galleryView: values.galleryView === 'assets' ? 'assets' : 'images', - imageOrderDir: settings.imageOrderDir, + action.preserveNavigationQuery && existingNavigationQuery + ? { + ...existingNavigationQuery, + ...getRankingStampFields(values, existingNavigationQuery, semanticKey), page: selectedImagePage, - paginationMode: settings.paginationMode, - searchTerm: typeof values.searchTerm === 'string' ? values.searchTerm : '', - starredOnly: values.starredOnly === true, - }, + } + : getCurrentGallerySelectionQuery(values, selectedImagePage), }; }, action.projectId diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index 20248c7b7b1..d1e89b9641e 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -159,19 +159,22 @@ const createCommands = ( itemKeys: ActionPayload<'setGalleryMultiSelection'>['itemKeys'], primaryItem: ActionPayload<'setGalleryMultiSelection'>['primaryItem'], projectId?: string, - selectionPage?: number - ) => ({ itemKeys, primaryItem, projectId, selectionPage }) + selectionPage?: number, + preserveNavigationQuery?: boolean + ) => ({ itemKeys, preserveNavigationQuery, primaryItem, projectId, selectionPage }) ), toggleItemSelection: command( 'toggleGalleryItemInSelection', ( item: ActionPayload<'toggleGalleryItemInSelection'>['item'], nextPrimaryItem: ActionPayload<'toggleGalleryItemInSelection'>['nextPrimaryItem'], - projectId?: string + projectId?: string, + selectionPage?: number ) => ({ item, nextPrimaryItem, projectId, + selectionPage, }) ), selectImage: ( @@ -187,6 +190,14 @@ const createCommands = ( selectionPage, type: 'selectGalleryItem', }), + /** Select an image found outside the Gallery; Preview navigates within the image's own board. */ + selectImageInItsBoard: (image: GeneratedImageContract & Partial, projectId?: string): void => + dispatch({ + item: legacyGeneratedImageToGalleryItem(image), + navigateItemBoard: true, + projectId, + type: 'selectGalleryItem', + }), setCompareImage: (image: (GeneratedImageContract & Partial) | null, projectId?: string): void => dispatch({ image: image ? legacyGeneratedImageToGalleryItem(image) : null, diff --git a/invokeai/frontend/webv2/tsconfig.json b/invokeai/frontend/webv2/tsconfig.json index 5fcd5f6c245..04a2163ccd6 100644 --- a/invokeai/frontend/webv2/tsconfig.json +++ b/invokeai/frontend/webv2/tsconfig.json @@ -18,11 +18,12 @@ "paths": { "@": ["./src"], "@/*": ["./src/*"], + "@api": ["../api"], + "@api/*": ["../api/*"], "@assets": ["./src/assets"], "@assets/*": ["./src/assets/*"], "@app": ["./src/app"], "@app/*": ["./src/app/*"], - "@api/*": ["../api/*"], "@features": ["./src/features"], "@features/*": ["./src/features/*"], "@platform": ["./src/platform"], diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index 095c6dc9d06..c060f6be154 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -448,6 +448,7 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), + '@api': fileURLToPath(new URL('../api', import.meta.url)), '@app': fileURLToPath(new URL('./src/app', import.meta.url)), '@assets': fileURLToPath(new URL('./src/assets', import.meta.url)), '@features': fileURLToPath(new URL('./src/features', import.meta.url)), diff --git a/tests/app/routers/test_gallery_item_location.py b/tests/app/routers/test_gallery_item_location.py new file mode 100644 index 00000000000..1fc8687017d --- /dev/null +++ b/tests/app/routers/test_gallery_item_location.py @@ -0,0 +1,179 @@ +"""Router tests for ordinary Gallery item-location lookup.""" + +import asyncio +from datetime import date +from types import SimpleNamespace +from typing import Any + +import anyio.to_thread +import httpx +import pytest +from fastapi import FastAPI, HTTPException, status + +from invokeai.app.api.auth_dependencies import get_current_user_or_default +from invokeai.app.api.routers import gallery as gallery_router_module +from invokeai.app.api.routers.gallery import get_gallery_item_location +from invokeai.app.services.auth.token_service import TokenData +from invokeai.app.services.gallery.gallery_common import GalleryItemKind, GalleryItemLocation +from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageRecordChanges, ResourceOrigin +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.app.services.video_records.video_records_common import VideoRecordChanges + + +def _save_image(invoker: Invoker, name: str, user_id: str, category: ImageCategory = ImageCategory.GENERAL) -> None: + invoker.services.image_records.save( + image_name=name, + image_origin=ResourceOrigin.INTERNAL, + image_category=category, + width=10, + height=10, + has_workflow=False, + is_intermediate=False, + user_id=user_id, + ) + + +def _save_video(invoker: Invoker, name: str, user_id: str) -> None: + invoker.services.video_records.save( + video_name=name, + video_origin=ResourceOrigin.INTERNAL, + video_category=ImageCategory.GENERAL, + width=10, + height=10, + duration=1.0, + fps=8.0, + has_workflow=False, + is_intermediate=False, + user_id=user_id, + ) + + +def _set_created_at(invoker: Invoker, table: str, name_column: str, name: str, created_at: str) -> None: + with invoker.services.image_records._db.transaction() as cursor: + cursor.execute(f"UPDATE {table} SET created_at = ? WHERE {name_column} = ?", (created_at, name)) + + +def _locate( + user_id: str, + kind: GalleryItemKind, + name: str, + **filters: Any, +) -> GalleryItemLocation: + return get_gallery_item_location( + current_user=TokenData(user_id=user_id, email=f"{user_id}@test.com", is_admin=False), + kind=kind, + name=name, + origin=filters.get("origin"), + categories=filters.get("categories"), + is_intermediate=filters.get("is_intermediate"), + board_id=filters.get("board_id"), + order_dir=filters.get("order_dir", SQLiteDirection.Descending), + starred=filters.get("starred"), + search_term=filters.get("search_term"), + created_from=filters.get("created_from"), + created_to=filters.get("created_to"), + ) + + +def test_location_forwards_ordinary_filters_and_returns_total_and_index( + enable_multiuser: Any, mock_invoker: Invoker +) -> None: + _save_image(mock_invoker, "plain.png", "alice") + _save_video(mock_invoker, "target.mp4", "alice") + _set_created_at(mock_invoker, "images", "image_name", "plain.png", "2026-05-02 10:00:00") + _set_created_at(mock_invoker, "videos", "video_name", "target.mp4", "2026-05-02 11:00:00") + mock_invoker.services.video_records.update("target.mp4", VideoRecordChanges(starred=True)) + board = mock_invoker.services.board_records.save("Gallery", "alice") + mock_invoker.services.board_video_records.add_video_to_board(board.board_id, "target.mp4") + + location = _locate( + "alice", + GalleryItemKind.VIDEO, + "target.mp4", + board_id=board.board_id, + categories=[ImageCategory.GENERAL], + is_intermediate=False, + order_dir=SQLiteDirection.Descending, + starred=True, + search_term="2026-05-02", + created_from=date(2026, 5, 2), + created_to=date(2026, 5, 2), + ) + + assert location == GalleryItemLocation(kind=GalleryItemKind.VIDEO, name="target.mp4", index=0, total=1) + + +def test_http_location_validates_query_and_serializes_response(monkeypatch: Any, mock_invoker: Invoker) -> None: + _save_image(mock_invoker, "http.png", "alice") + monkeypatch.setattr(gallery_router_module, "ApiDependencies", SimpleNamespace(invoker=mock_invoker)) + + async def run_sync_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + # This environment's AnyIO blocking portal cannot wake its event loop. Run this small + # synchronous handler inline while keeping the ASGI request/validation/serialization path real. + monkeypatch.setattr(anyio.to_thread, "run_sync", run_sync_inline) + + app = FastAPI() + app.include_router(gallery_router_module.gallery_router, prefix="/api") + app.dependency_overrides[get_current_user_or_default] = lambda: TokenData( + user_id="alice", email="alice@test.com", is_admin=False + ) + + async def make_requests() -> tuple[httpx.Response, httpx.Response]: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.get( + "/api/v1/gallery/items/location", + params={"kind": "image", "name": "http.png", "order_dir": "ASC"}, + ) + invalid = await client.get("/api/v1/gallery/items/location", params={"name": "http.png"}) + return response, invalid + + response, invalid = asyncio.run(make_requests()) + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == {"kind": "image", "name": "http.png", "index": 0, "total": 1} + assert invalid.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_location_order_uses_created_time_without_starred_first(enable_multiuser: Any, mock_invoker: Invoker) -> None: + _save_image(mock_invoker, "starred.png", "alice") + _save_image(mock_invoker, "newer.png", "alice") + _set_created_at(mock_invoker, "images", "image_name", "starred.png", "2026-01-01 10:00:00") + _set_created_at(mock_invoker, "images", "image_name", "newer.png", "2026-01-02 10:00:00") + mock_invoker.services.image_records.update("starred.png", ImageRecordChanges(starred=True)) + + location = _locate("alice", GalleryItemKind.IMAGE, "newer.png") + + assert location == GalleryItemLocation(kind=GalleryItemKind.IMAGE, name="newer.png", index=0, total=2) + + +def test_missing_filtered_and_cross_user_targets_share_same_not_found( + enable_multiuser: Any, mock_invoker: Invoker +) -> None: + _save_image(mock_invoker, "foreign.png", "bob") + + responses = [] + for user_id, name, filters in [ + ("alice", "missing.png", {}), + ("alice", "foreign.png", {}), + ("bob", "foreign.png", {"categories": [ImageCategory.CONTROL]}), + ]: + with pytest.raises(HTTPException) as error: + _locate(user_id, GalleryItemKind.IMAGE, name, **filters) + responses.append((error.value.status_code, error.value.detail)) + + assert responses == [(status.HTTP_404_NOT_FOUND, "Gallery item not found")] * 3 + + +def test_inaccessible_explicit_board_keeps_existing_forbidden_behavior( + enable_multiuser: Any, mock_invoker: Invoker +) -> None: + board = mock_invoker.services.board_records.save("Private", "bob") + + with pytest.raises(HTTPException) as error: + _locate("alice", GalleryItemKind.IMAGE, "anything.png", board_id=board.board_id) + + assert error.value.status_code == status.HTTP_403_FORBIDDEN + assert error.value.detail == "Not authorized to access this board" diff --git a/tests/app/services/gallery/test_gallery_default.py b/tests/app/services/gallery/test_gallery_default.py index e4c775cb7ca..5f39115522d 100644 --- a/tests/app/services/gallery/test_gallery_default.py +++ b/tests/app/services/gallery/test_gallery_default.py @@ -713,29 +713,28 @@ def test_board_image_count_starts_from_membership(self, services) -> None: ) +def _seed_same_timestamp(services) -> None: + _save_image(services["images"], "b.png", user_id="alice") + _save_image(services["images"], "a.png", user_id="alice") + _save_video(services["videos"], "b.mp4", user_id="alice") + _save_video(services["videos"], "a.mp4", user_id="alice") + for table, col, name in [ + ("images", "image_name", "a.png"), + ("images", "image_name", "b.png"), + ("videos", "video_name", "a.mp4"), + ("videos", "video_name", "b.mp4"), + ]: + _backdate(services, table, col, name, "2026-01-05 12:00:00") + + class TestOrderingTieBreakers: """PR #9163 review fix: ordering only by (starred, created_at) left images and videos created within the same timestamp granularity with no defined relative order — rows could reorder across refetches or shift between offset pages, and the virtual-board cover could flicker between equally-new items.""" - SAME_TS = "2026-01-05 12:00:00" - - def _seed_same_timestamp(self, services) -> None: - _save_image(services["images"], "b.png", user_id="alice") - _save_image(services["images"], "a.png", user_id="alice") - _save_video(services["videos"], "b.mp4", user_id="alice") - _save_video(services["videos"], "a.mp4", user_id="alice") - for table, col, name in [ - ("images", "image_name", "a.png"), - ("images", "image_name", "b.png"), - ("videos", "video_name", "a.mp4"), - ("videos", "video_name", "b.mp4"), - ]: - _backdate(services, table, col, name, self.SAME_TS) - def test_same_timestamp_order_is_deterministic(self, services) -> None: - self._seed_same_timestamp(services) + _seed_same_timestamp(services) gallery = services["gallery"] first = [(i.kind, i.name) for i in gallery.list_item_names(user_id="alice", is_admin=False).items] @@ -754,7 +753,7 @@ def test_same_timestamp_order_is_deterministic(self, services) -> None: def test_ascending_is_mirror_of_descending(self, services) -> None: from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection - self._seed_same_timestamp(services) + _seed_same_timestamp(services) gallery = services["gallery"] desc = [(i.kind, i.name) for i in gallery.list_item_names(user_id="alice", is_admin=False).items] @@ -765,7 +764,7 @@ def test_ascending_is_mirror_of_descending(self, services) -> None: assert asc == list(reversed(desc)) def test_same_timestamp_cover_is_deterministic(self, services) -> None: - self._seed_same_timestamp(services) + _seed_same_timestamp(services) gallery = services["gallery"] covers = set() @@ -778,6 +777,119 @@ def test_same_timestamp_cover_is_deterministic(self, services) -> None: assert covers == {(None, "b.mp4")} +class TestGetItemLocation: + def _assert_locations_match_listing(self, services, **filters) -> list[tuple[GalleryItemKind, str]]: + gallery = services["gallery"] + listing = gallery.list_item_names(starred_first=False, **filters) + refs = [(item.kind, item.name) for item in listing.items] + + for index, (kind, name) in enumerate(refs): + location = gallery.get_item_location(kind=kind, name=name, **filters) + assert location is not None + assert (location.kind, location.name, location.index, location.total) == ( + kind, + name, + index, + len(refs), + ) + + return refs + + @pytest.mark.parametrize("order_dir", [SQLiteDirection.Descending, SQLiteDirection.Ascending]) + def test_location_matches_exact_order_with_equal_timestamp_tie_breakers(self, services, order_dir) -> None: + _seed_same_timestamp(services) + refs = self._assert_locations_match_listing( + services, + user_id="alice", + is_admin=False, + order_dir=order_dir, + ) + + descending = [ + (GalleryItemKind.VIDEO, "b.mp4"), + (GalleryItemKind.VIDEO, "a.mp4"), + (GalleryItemKind.IMAGE, "b.png"), + (GalleryItemKind.IMAGE, "a.png"), + ] + assert refs == (descending if order_dir == SQLiteDirection.Descending else list(reversed(descending))) + + def test_location_uses_board_category_view_search_date_star_and_user_filters(self, services) -> None: + _save_image(services["images"], "general.png", user_id="alice") + _save_image(services["images"], "control.png", user_id="alice", category=ImageCategory.CONTROL) + _save_image(services["images"], "bob.png", user_id="bob") + _save_video(services["videos"], "general.mp4", user_id="alice") + _save_marked_video(services["videos"], "external.mp4", user_id="alice", metadata=None) + board = services["boards"].save("Gallery", "alice") + services["board_images"].add_image_to_board(board.board_id, "general.png") + services["board_videos"].add_video_to_board(board.board_id, "general.mp4") + + for table, name_col, name, created_at in [ + ("images", "image_name", "general.png", "2026-05-02 10:00:00"), + ("images", "image_name", "control.png", "2026-05-03 10:00:00"), + ("images", "image_name", "bob.png", "2026-05-02 10:00:00"), + ("videos", "video_name", "general.mp4", "2026-05-02 11:00:00"), + ("videos", "video_name", "external.mp4", "2026-05-02 12:00:00"), + ]: + _backdate(services, table, name_col, name, created_at) + _star(services, "videos", "video_name", "general.mp4") + with services["images"]._db.transaction() as cursor: + cursor.execute("UPDATE images SET is_intermediate = 1 WHERE image_name = ?", ("control.png",)) + + common = {"user_id": "alice", "is_admin": False} + filter_sets = [ + {"board_id": board.board_id}, + {"board_id": "none"}, + {"origin": ResourceOrigin.EXTERNAL}, + {"categories": [ImageCategory.GENERAL]}, + {"categories": [ImageCategory.CONTROL], "is_intermediate": True}, + {"is_intermediate": False}, + {"search_term": "2026-05-02"}, + {"created_from": "2026-05-02", "created_to": "2026-05-02"}, + {"starred": True}, + {"starred": False}, + { + "board_id": board.board_id, + "categories": [ImageCategory.GENERAL], + "is_intermediate": False, + "search_term": "2026-05-02", + "created_from": "2026-05-02", + "created_to": "2026-05-02", + "starred": True, + }, + ] + for filters in filter_sets: + self._assert_locations_match_listing(services, **common, **filters) + + assert ( + services["gallery"].get_item_location( + kind=GalleryItemKind.IMAGE, + name="bob.png", + **common, + ) + is None + ) + assert ( + services["gallery"].get_item_location( + kind=GalleryItemKind.IMAGE, + name="control.png", + categories=[ImageCategory.GENERAL], + **common, + ) + is None + ) + + def test_missing_target_returns_none(self, services) -> None: + assert ( + services["gallery"].get_item_location( + kind=GalleryItemKind.IMAGE, + name="missing.png", + user_id="alice", + is_admin=False, + ) + is None + ) + + def _save_marked_video(store: SqliteVideoRecordStorage, name: str, user_id: str, metadata: str | None) -> None: store.save( video_name=name,