fix(ui): skip optimistic updates for gallery when using search term

This commit is contained in:
psychedelicious
2025-07-05 19:20:04 +10:00
parent 6bd004d868
commit 61a35f1396
10 changed files with 63 additions and 50 deletions

View File

@@ -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

View File

@@ -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 })

View File

@@ -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<HTMLDivElement>) => {
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

View File

@@ -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<typeof selectListImageNamesQueryArgs>;
type ListImageNamesQueryArgs = ReturnType<typeof selectGetImageNamesQueryArgs>;
type GridContext = {
queryArgs: ListImageNamesQueryArgs;

View File

@@ -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<typeof useGetImageNamesQuery>[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 };

View File

@@ -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,

View File

@@ -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<GetImageNamesResult, GetImageNamesArgs>({
query: (queryArgs) => ({
url: buildImagesUrl('names', queryArgs),
method: 'GET',

View File

@@ -7,7 +7,9 @@ export type S = components['schemas'];
export type ListImagesArgs = NonNullable<paths['/api/v1/images/']['get']['parameters']['query']>;
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<paths['/api/v1/images/names']['get']['parameters']['query']>;
export type ListBoardsArgs = NonNullable<paths['/api/v1/boards/']['get']['parameters']['query']>;
@@ -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<paths['/api/v1/images/names']['get']['parameters']['query']>;
// Models
export type ModelType = S['ModelType'];

View File

@@ -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;

View File

@@ -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<string, number> = {};
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