diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index ed1c0207ac..e5b57407f7 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,6 +1,6 @@ import { isAnyOf } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { selectListImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; @@ -20,7 +20,7 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) const board_id = selectSelectedBoardId(state); - const queryArgs = { ...selectListImageNamesQueryArgs(state), board_id }; + const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id }; // wait until the board has some images - maybe it already has some from a previous fetch // must use getState() to ensure we do not have stale state diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts index 5ca2bd1818..0c19eda02e 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -11,7 +11,7 @@ import { import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types'; import type { ImageUsage } from 'features/deleteImageModal/store/types'; -import { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; @@ -80,7 +80,7 @@ const handleDeletions = async (image_names: string[], store: AppStore) => { try { const { dispatch, getState } = store; const state = getState(); - const { data } = imagesApi.endpoints.getImageNames.select(selectListImageNamesQueryArgs(state))(state); + const { data } = imagesApi.endpoints.getImageNames.select(selectGetImageNamesQueryArgs(state))(state); const index = data?.image_names.findIndex((name) => name === image_names[0]); const { deleted_images } = await dispatch( imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false }) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index b0000fbe44..483c3ea0f7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -16,7 +16,7 @@ import { useImageContextMenu } from 'features/gallery/components/ImageContextMen import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; import { - selectListImageNamesQueryArgs, + selectGetImageNamesQueryArgs, selectSelectedBoardId, selectSelection, } from 'features/gallery/store/gallerySelectors'; @@ -93,7 +93,7 @@ const buildOnClick = (imageName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => { const { shiftKey, ctrlKey, metaKey, altKey } = e; const state = getState(); - const queryArgs = selectListImageNamesQueryArgs(state); + const queryArgs = selectGetImageNamesQueryArgs(state); const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data?.image_names ?? []; // If we don't have the image names cached, we can't perform selection operations diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index fce0ba86e3..624d7066e2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -3,7 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; -import type { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, selectImageToCompare, @@ -32,7 +32,7 @@ import { useGalleryImageNames } from './use-gallery-image-names'; const log = logger('gallery'); -type ListImageNamesQueryArgs = ReturnType; +type ListImageNamesQueryArgs = ReturnType; type GridContext = { queryArgs: ListImageNamesQueryArgs; diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts index 07050bf58d..c81728a1b2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -1,6 +1,6 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { useGetImageNamesQuery } from 'services/api/endpoints/images'; import { useDebounce } from 'use-debounce'; @@ -14,7 +14,7 @@ const getImageNamesQueryOptions = { } satisfies Parameters[1]; export const useGalleryImageNames = () => { - const _queryArgs = useAppSelector(selectListImageNamesQueryArgs); + const _queryArgs = useAppSelector(selectGetImageNamesQueryArgs); const [queryArgs] = useDebounce(_queryArgs, 300); const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); return { imageNames, isLoading, isFetching, queryArgs }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 6a32cdc71f..490305f8af 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -2,7 +2,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { ListBoardsArgs } from 'services/api/types'; +import type { GetImageNamesArgs, ListBoardsArgs } from 'services/api/types'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); @@ -27,7 +27,7 @@ const selectGallerySearchTerm = createSelector(selectGallerySlice, (gallery) => const selectGalleryOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.orderDir); const selectGalleryStarredFirst = createSelector(selectGallerySlice, (gallery) => gallery.starredFirst); -export const selectListImageNamesQueryArgs = createMemoizedSelector( +export const selectGetImageNamesQueryArgs = createMemoizedSelector( [ selectSelectedBoardId, selectGalleryQueryCategories, @@ -35,7 +35,7 @@ export const selectListImageNamesQueryArgs = createMemoizedSelector( selectGalleryOrderDir, selectGalleryStarredFirst, ], - (board_id, categories, search_term, order_dir, starred_first) => ({ + (board_id, categories, search_term, order_dir, starred_first): GetImageNamesArgs => ({ board_id, categories, search_term, diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 3d9f09d161..aa6877e9a4 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -4,15 +4,14 @@ import { getStore } from 'app/store/nanostores/store'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; import type { components, paths } from 'services/api/schema'; import type { + GetImageNamesArgs, + GetImageNamesResult, GraphAndWorkflowResponse, - ImageCategory, ImageDTO, - ImageNamesResult, ImageUploadEntryRequest, ImageUploadEntryResponse, ListImagesArgs, ListImagesResponse, - SQLiteDirection, UploadImageArg, } from 'services/api/types'; import { getListImagesUrl } from 'services/api/util'; @@ -419,16 +418,7 @@ export const imagesApi = api.injectEndpoints({ /** * Get ordered list of image names for selection operations */ - getImageNames: build.query< - ImageNamesResult, - { - categories?: ImageCategory[] | null; - is_intermediate?: boolean | null; - board_id?: string | null; - search_term?: string | null; - order_dir?: SQLiteDirection; - } - >({ + getImageNames: build.query({ query: (queryArgs) => ({ url: buildImagesUrl('names', queryArgs), method: 'GET', diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 11f6eec7ca..8ac1729e9b 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -7,7 +7,9 @@ export type S = components['schemas']; export type ListImagesArgs = NonNullable; export type ListImagesResponse = paths['/api/v1/images/']['get']['responses']['200']['content']['application/json']; -export type ImageNamesResult = S['ImageNamesResult']; +export type GetImageNamesResult = + paths['/api/v1/images/names']['get']['responses']['200']['content']['application/json']; +export type GetImageNamesArgs = NonNullable; export type ListBoardsArgs = NonNullable; @@ -36,6 +38,7 @@ export type ImageDTO = S['ImageDTO']; export type BoardDTO = S['BoardDTO']; export type ImageCategory = S['ImageCategory']; export type OffsetPaginatedResults_ImageDTO_ = S['OffsetPaginatedResults_ImageDTO_']; +export type GetImageNamesArg = NonNullable; // Models export type ModelType = S['ModelType']; diff --git a/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts b/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts index f8e421a3a1..772dc077fb 100644 --- a/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts +++ b/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts @@ -1,5 +1,5 @@ import type { OrderDir } from 'features/gallery/store/types'; -import type { ImageDTO, ImageNamesResult } from 'services/api/types'; +import type { GetImageNamesResult, ImageDTO } from 'services/api/types'; /** * Calculates the optimal insertion position for a new image in the names list. @@ -31,11 +31,11 @@ function calculateImageInsertionPosition( * Optimistically inserts a new image into the ImageNamesResult at the correct position */ export function insertImageIntoNamesResult( - currentResult: ImageNamesResult, + currentResult: GetImageNamesResult, imageDTO: ImageDTO, starredFirst: boolean, orderDir: OrderDir = 'DESC' -): ImageNamesResult { +): GetImageNamesResult { // Don't insert if the image is already in the list if (currentResult.image_names.includes(imageDTO.image_name)) { return currentResult; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 172f4e74d6..1bbbb9a007 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -4,7 +4,7 @@ import { deepClone } from 'common/util/deepClone'; import { selectAutoSwitch, selectGalleryView, - selectListImageNamesQueryArgs, + selectGetImageNamesQueryArgs, selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; @@ -17,6 +17,7 @@ import type { ImageDTO, S } from 'services/api/types'; import { getCategories } from 'services/api/util'; import { insertImageIntoNamesResult } from 'services/api/util/optimisticUpdates'; import { $lastProgressEvent } from 'services/events/stores'; +import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -40,7 +41,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi // For efficiency's sake, we want to minimize the number of dispatches and invalidations we do. // We'll keep track of each change we need to make and do them all at once. const boardTotalAdditions: Record = {}; - const listImageNamesArg = selectListImageNamesQueryArgs(getState()); + const getImageNamesArg = selectGetImageNamesQueryArgs(getState()); for (const imageDTO of imageDTOs) { if (imageDTO.is_intermediate) { @@ -70,26 +71,38 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi } dispatch(boardsApi.util.upsertQueryEntries(entries)); - // Optimistically update image names lists - DTOs are already cached by getResultImageDTOs - const state = getState(); - + /** + * Optimistic update and cache invalidation for image names queries that match this image's board and categories. + * - Optimistic update for the cache that does not have a search term (we cannot derive the correct insertion + * position when a search term is present). + * - Cache invalidation for the query that has a search term, so it will be refetched. + * + * Note: The image DTO objects are already implicitly cached by the getResultImageDTOs function. We do not need + * to explicitly cache them again here. + */ for (const imageDTO of imageDTOs) { - // Construct the expected query args for this image's getImageNames query - // Use the current gallery query args as base, but override board_id and categories for this specific image - const expectedQueryArgs = { - ...listImageNamesArg, + // Override board_id and categories for this specific image to build the "expected" args for the query. + const imageSpecificArgs = { categories: getCategories(imageDTO), board_id: imageDTO.board_id ?? 'none', }; - // Check if we have cached image names for this query - const cachedNamesResult = imagesApi.endpoints.getImageNames.select(expectedQueryArgs)(state); + const expectedQueryArgs = { + ...getImageNamesArg, + ...imageSpecificArgs, + search_term: '', + }; - if (cachedNamesResult.data) { - // We have cached names - optimistically insert the new image - dispatch( - imagesApi.util.updateQueryData('getImageNames', expectedQueryArgs, (draft) => { - // Use the utility function to insert at the correct position + // If the cache for the query args provided here does not exist, RTK Query will ignore the update. + dispatch( + imagesApi.util.updateQueryData( + 'getImageNames', + { + ...getImageNamesArg, + ...imageSpecificArgs, + search_term: '', + }, + (draft) => { const updatedResult = insertImageIntoNamesResult( draft, imageDTO, @@ -97,14 +110,21 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi expectedQueryArgs.order_dir ); - // Replace the draft contents draft.image_names = updatedResult.image_names; draft.starred_count = updatedResult.starred_count; draft.total_count = updatedResult.total_count; - }) - ); + } + ) + ); + + // If there is a search term present, we need to invalidate that query to ensure the search results are updated. + if (getImageNamesArg.search_term) { + const expectedQueryArgs = { + ...getImageNamesArg, + ...imageSpecificArgs, + }; + dispatch(imagesApi.util.invalidateTags([{ type: 'ImageNameList', id: stableHash(expectedQueryArgs) }])); } - // If no cached data, we don't need to do anything - there's no list to update } // No need to invalidate tags since we're doing optimistic updates