diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx index 68abf517005..b664454748d 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx @@ -76,7 +76,7 @@ export const ConversationCells = memo( const {fireAndForgetInvoker} = useApplicationContext(); const {cellsState: initialCellState, name} = useKoSubscribableChildren(activeConversation, ['cellsState', 'name']); - const {getNodes, status: nodesStatus, getPagination, error: storeError} = useCellsStore(); + const {getNodes, status: nodesStatus, getPagination, error: storeError, clearAll} = useCellsStore(); const conversationId = activeConversation.id; const conversationQualifiedId = activeConversation.qualifiedId; @@ -135,11 +135,12 @@ export const ConversationCells = memo( if (wasSearchViewOpen.current && !isSearchViewOpen) { // Search view just closed — reset any active search/filter and restore the // browse-mode dataset (handled by clearSearch's onClear callback → refresh). + clearAll({conversationId}); clearAllFilters(); clearSearch({preserveFilters: false}); } wasSearchViewOpen.current = isSearchViewOpen; - }, [clearAllFilters, clearSearch, isSearchViewOpen]); + }, [clearAll, clearAllFilters, clearSearch, conversationId, isSearchViewOpen]); const handleRefresh = useCallback((): void => { if (isInSearchMode) { diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/requestVersionGate.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/requestVersionGate.test.ts new file mode 100644 index 00000000000..5ece9377f6b --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/requestVersionGate.test.ts @@ -0,0 +1,41 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createRequestVersionGate} from './requestVersionGate'; + +describe('createRequestVersionGate', () => { + it('marks older requests as stale when a newer request starts', () => { + const requestVersionGate = createRequestVersionGate(); + + const firstRequest = requestVersionGate.next(); + const secondRequest = requestVersionGate.next(); + + expect(requestVersionGate.isStale(firstRequest)).toBe(true); + expect(requestVersionGate.isStale(secondRequest)).toBe(false); + }); + + it('invalidates in-flight requests without starting a replacement request', () => { + const requestVersionGate = createRequestVersionGate(); + + const request = requestVersionGate.next(); + requestVersionGate.invalidate(); + + expect(requestVersionGate.isStale(request)).toBe(true); + }); +}); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/requestVersionGate.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/requestVersionGate.ts new file mode 100644 index 00000000000..644f6a90ed7 --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/requestVersionGate.ts @@ -0,0 +1,41 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export interface RequestVersionGate { + next: () => number; + invalidate: () => void; + isStale: (requestId: number) => boolean; +} + +export const createRequestVersionGate = (): RequestVersionGate => { + let currentRequestId = 0; + + return { + next(): number { + currentRequestId += 1; + return currentRequestId; + }, + invalidate(): void { + currentRequestId += 1; + }, + isStale(requestId: number): boolean { + return requestId !== currentRequestId; + }, + }; +}; diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts index a916489a730..19aee92d610 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts @@ -18,7 +18,7 @@ */ import {act, renderHook, waitFor} from '@testing-library/react'; -import {RestNodeCollection} from 'cells-sdk-ts'; +import {RestNode, RestNodeCollection} from 'cells-sdk-ts'; import {CellsRepository} from 'Repositories/cells/cellsRepository'; import {UserRepository} from 'Repositories/user/userRepository'; @@ -56,6 +56,10 @@ const staleFolderNode: CellNode = { user: null, }; +function createRestNode(name: string, uuid = name): RestNode { + return {Path: `${CONV_ID}@${DOMAIN}/${name}`, Type: 'LEAF', Uuid: uuid}; +} + type FakeCellsRepository = jest.Mocked>; type FakeUserRepository = jest.Mocked>; @@ -267,4 +271,50 @@ describe('useConversationSearchFiles', () => { expect(onClear).toHaveBeenCalled(); }); + + it('clears search-owned rows before handing control back to browse mode', async () => { + const onClear = jest.fn(); + const {result, fireAndForgetInvoker} = renderSearchHook({onClear}); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + useCellsStore.getState().setNodes({ + conversationId: CONV_ID, + nodes: [staleFolderNode], + }); + useCellsStore.getState().setStatus('success'); + + act(() => result.current.handleSearch('')); + + expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toEqual([]); + expect(useCellsStore.getState().getPagination({conversationId: CONV_ID})).toBeNull(); + expect(onClear).toHaveBeenCalled(); + }); + + it('does not write stale search results after the search input is cleared', async () => { + const staleSearch = createDeferred(); + const cellsRepository = createFakeCellsRepository(); + cellsRepository.searchNodes.mockImplementation(({query}) => { + if (query === 'stale') { + return staleSearch.promise; + } + return Promise.resolve({Nodes: []}); + }); + const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); + const {result} = renderSearchHook({cellsRepository, fireAndForgetInvoker}); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + act(() => result.current.handleSearch('stale')); + await waitFor(() => + expect(cellsRepository.searchNodes).toHaveBeenCalledWith(expect.objectContaining({query: 'stale'})), + ); + + act(() => result.current.handleSearch('')); + + act(() => { + staleSearch.resolve({Nodes: [createRestNode('stale-file.txt')]}); + }); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toEqual([]); + }); }); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts index b3dcb936c1d..2d38ea7e1ee 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts @@ -28,6 +28,8 @@ import {FireAndForgetInvoker} from '@wireapp/core'; import {CellsRepository} from 'Repositories/cells/cellsRepository'; import {UserRepository} from 'Repositories/user/userRepository'; +import {createRequestVersionGate} from './requestVersionGate'; + import { ConversationDriveFiltersState, hasActiveSearchParams, @@ -71,6 +73,7 @@ export const useConversationSearchFiles = ({ const shouldPerformSearch = useRef(false); const hasFiredInitialFetchRef = useRef(false); const wasEnabledRef = useRef(false); + const requestVersionGate = useRef(createRequestVersionGate()); // Prevents stale in-flight responses from overwriting the store after the search view closes. const enabledRef = useRef(enabled); enabledRef.current = enabled; @@ -81,6 +84,10 @@ export const useConversationSearchFiles = ({ const {id, domain} = conversationQualifiedId; + const isCurrentSearchRequest = useCallback((requestVersion: number): boolean => { + return enabledRef.current && !requestVersionGate.current.isStale(requestVersion); + }, []); + const searchNodes = useCallback( async ({ query, @@ -93,6 +100,8 @@ export const useConversationSearchFiles = ({ offset?: number; append?: boolean; }) => { + const requestVersion = requestVersionGate.current.next(); + try { setError(null); setStatus(append ? 'fetchingMore' : 'loading'); @@ -124,8 +133,7 @@ export const useConversationSearchFiles = ({ ...searchParams, }); - // Search may have closed while the lookup request was in flight. - if (!enabledRef.current) { + if (!isCurrentSearchRequest(requestVersion)) { return; } @@ -140,8 +148,7 @@ export const useConversationSearchFiles = ({ const users = await getUsersFromNodes({nodes: result.Nodes, userRepository}); - // Search may also close while resolving node owners. - if (!enabledRef.current) { + if (!isCurrentSearchRequest(requestVersion)) { return; } @@ -159,6 +166,10 @@ export const useConversationSearchFiles = ({ setPagination({conversationId: id, pagination}); setStatus('success'); } catch (error) { + if (!isCurrentSearchRequest(requestVersion)) { + return; + } + const wrappedError = error instanceof Error ? error : new Error('Failed to load files', {cause: error}); setError(wrappedError); @@ -175,7 +186,7 @@ export const useConversationSearchFiles = ({ }, // cellsRepository and userRepository are not dependencies because they're singletons // eslint-disable-next-line react-hooks/exhaustive-deps - [appendNodes, setNodes, setPagination, setStatus, setError, id, domain], + [appendNodes, setNodes, setPagination, setStatus, setError, id, domain, isCurrentSearchRequest], ); useLayoutEffect(() => { @@ -227,19 +238,25 @@ export const useConversationSearchFiles = ({ preserveInputValue = false, }: {preserveFilters?: boolean; preserveInputValue?: boolean} = {}) => { searchNodesDebounced.cancel(); + requestVersionGate.current.invalidate(); if (!preserveInputValue) { setSearchValue(''); } setSearchQuery(''); shouldPerformSearch.current = false; - if (preserveFilters && hasActiveParams) { + const shouldRefreshSearchResults = preserveFilters && hasActiveParams; + + if (shouldRefreshSearchResults) { fireAndForgetInvoker.fireAndForget(async (): Promise => { await searchNodes({query: '', filters}); }); return; } + // Search and browse share the same store. Clear search-owned rows before browse + // takes control again so stale search results cannot render with browse pagination. + clearAll({conversationId: id}); onClear?.(); };