From 9ff490e68d7337ac419119c84de16ebbdd36d5d0 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 15 Jun 2026 19:58:37 -0300 Subject: [PATCH 1/6] fix: make navbar spotlight search show cached subscriptions instantly The local subscription query lived inside useSearchItems but received the 500ms-debounced filter, so cached results were delayed just like the server call. Run the local useUserSubscriptions query against the immediate filter text and debounce only the /v1/spotlight request, then merge local + server results reactively (outside the query) so joined rooms render instantly while the server response loads. Returns an explicit { items, isLoading } shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NavBarSearch/hooks/useSearchItems.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index 97b6b077d5b52..d3b3543ee948b 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -1,7 +1,8 @@ +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { useEndpoint, useUserSubscriptions } from '@rocket.chat/ui-contexts'; -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { getConfig } from '../../../lib/utils/getConfig'; @@ -16,9 +17,9 @@ const options = { limit: LIMIT, } as const; -// FIXME: the return type is UTTERLY wrong, but I'm not sure what it should be -export const useSearchItems = (filterText: string): UseQueryResult => { +export const useSearchItems = (filterText: string): { items: SubscriptionWithRoom[]; isLoading: boolean } => { const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]); + const query = useMemo(() => { const filterRegex = new RegExp(escapeRegExp(name), 'i'); @@ -30,8 +31,13 @@ export const useSearchItems = (filterText: string): UseQueryResult (t === 'd' ? name : null))].filter(Boolean) as string[]; const searchForChannels = mention === '#'; @@ -49,16 +55,16 @@ export const useSearchItems = (filterText: string): UseQueryResult _id + name)], + const { data: serverResults, isFetching } = useQuery({ + // Keyed on the debounced term only, so typing doesn't refetch on every keystroke. + queryKey: ['sidebar/search/spotlight', debouncedName, mention, type], - queryFn: async () => { - if (localRooms.length === LIMIT) { - return localRooms; - } + // When local subscriptions already fill the limit there's nothing more to fetch. + enabled: localRooms.length < LIMIT, + queryFn: async () => { const spotlight = await getSpotlight({ - query: name, + query: debouncedName, usernames: usernamesFromClient.join(','), type: JSON.stringify(type), }); @@ -108,11 +114,21 @@ export const useSearchItems = (filterText: string): UseQueryResult [item.name, item.fname].includes(name)); - return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])); + return resultsFromServer; }, staleTime: 60_000, - placeholderData: (previousData) => previousData ?? localRooms, + // Keep the previous server results visible while a new search is in flight. + placeholderData: (previousData) => previousData, }); + + // Merge reactively (outside the query) so local results render the instant the user types + // and server results fold in — deduped — once they arrive. + const items = useMemo(() => { + const fromServer = serverResults ?? []; + const exact = fromServer.filter((item) => [item.name, item.fname].includes(name)); + return Array.from(new Set([...exact, ...localRooms, ...fromServer])) as SubscriptionWithRoom[]; + }, [serverResults, localRooms, name]); + + return { items, isLoading: isFetching }; }; From c44d291ffab6393f47f56b741af6370856bdb81e Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 15 Jun 2026 19:58:46 -0300 Subject: [PATCH 2/6] feat: add loading indicator to navbar spotlight search Pass the raw filter text to useSearchItems (the hook now owns debouncing) and render an accessible Throbber at the bottom of the results while the server spotlight request is in flight, so users see cached results immediately and know more are still loading. The spinner is decorative (aria-hidden, not a listbox option); loading is announced via aria-busy plus a role="status" live region. Reuses the existing "Loading" i18n key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../navbar/NavBarSearch/NavBarSearchListbox.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index 1b3ef415fa414..a7efb8c55fe4c 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -1,7 +1,8 @@ import type { OverlayTriggerAria } from '@react-aria/overlays'; +import { VisuallyHidden } from '@react-aria/visually-hidden'; import type { OverlayTriggerState } from '@react-stately/overlays'; -import { Box, Tile } from '@rocket.chat/fuselage'; -import { useDebouncedValue, useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks'; +import { Box, Throbber, Tile } from '@rocket.chat/fuselage'; +import { useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; import { useRef } from 'react'; import { useFormContext } from 'react-hook-form'; @@ -28,14 +29,12 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) const { resetField, watch } = useFormContext(); const { filterText } = watch(); - const debouncedFilter = useDebouncedValue(filterText, 500); - const handleSelect = useStableCallback(() => { state.close(); resetField('filterText'); }); - const { data: items = [], isLoading } = useSearchItems(debouncedFilter); + const { items, isLoading } = useSearchItems(filterText); return ( + {isLoading ? t('Loading') : ''}
{items.length === 0 && !isLoading && } @@ -63,6 +63,11 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) {items.map((item) => ( ))} + {isLoading && ( + + + + )}
From 53e37da21fa32a8495297b74e38838fd17d23c82 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 15:00:42 -0300 Subject: [PATCH 3/6] use a skeleton instead of a throbber --- .../NavBarSearch/NavBarSearchItemSkeleton.tsx | 20 +++++++++++++++++++ .../NavBarSearch/NavBarSearchListbox.tsx | 9 +++------ 2 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 apps/meteor/client/navbar/NavBarSearch/NavBarSearchItemSkeleton.tsx diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItemSkeleton.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItemSkeleton.tsx new file mode 100644 index 0000000000000..a99a3c9dcdb62 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItemSkeleton.tsx @@ -0,0 +1,20 @@ +import { SidebarV2Item, SidebarV2ItemAvatarWrapper, SidebarV2ItemTitle, Skeleton } from '@rocket.chat/fuselage'; + +// Placeholder row shown while the server spotlight results are still loading. +// Decorative only: it has no `role='option'` so keyboard navigation skips it +// (see useSearchNavigation), and `tabIndex={-1}` + `pointerEvents='none'` keep +// it out of reach of focus and mouse clicks. Loading is conveyed via aria-busy. +const NavBarSearchItemSkeleton = () => { + return ( + + + + + + + + + ); +}; + +export default NavBarSearchItemSkeleton; diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index a7efb8c55fe4c..10dc8ebcfcad9 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -1,13 +1,14 @@ import type { OverlayTriggerAria } from '@react-aria/overlays'; import { VisuallyHidden } from '@react-aria/visually-hidden'; import type { OverlayTriggerState } from '@react-stately/overlays'; -import { Box, Throbber, Tile } from '@rocket.chat/fuselage'; +import { Box, Tile } from '@rocket.chat/fuselage'; import { useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; import { useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import NavBarSearchItemSkeleton from './NavBarSearchItemSkeleton'; import NavBarSearchNoResults from './NavBarSearchNoResults'; import NavBarSearchRow from './NavBarSearchRow'; import { useSearchItems } from './hooks/useSearchItems'; @@ -63,11 +64,7 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) {items.map((item) => ( ))} - {isLoading && ( - - - - )} + {isLoading && Array.from({ length: 4 }, (_, index) => )} From 50daaaf1d9ce056bab1638dd667a8bd455c8bc6d Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 15:06:53 -0300 Subject: [PATCH 4/6] fix showing previous results --- .../client/navbar/NavBarSearch/hooks/useSearchItems.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index d3b3543ee948b..027972bba857f 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -125,7 +125,14 @@ export const useSearchItems = (filterText: string): { items: SubscriptionWithRoo // Merge reactively (outside the query) so local results render the instant the user types // and server results fold in — deduped — once they arrive. const items = useMemo(() => { - const fromServer = serverResults ?? []; + // Server results are keyed on the *debounced* term, so while the user is still typing + // (or via placeholderData) they may belong to a previous search. Drop the ones that no + // longer match the current text so stale, non-matching results aren't rendered. + const filterRegex = new RegExp(escapeRegExp(name), 'i'); + const matchesFilter = ({ name, fname }: { name?: string; fname?: string }) => + (name && filterRegex.test(name)) || (fname && filterRegex.test(fname)); + + const fromServer = (serverResults ?? []).filter(matchesFilter); const exact = fromServer.filter((item) => [item.name, item.fname].includes(name)); return Array.from(new Set([...exact, ...localRooms, ...fromServer])) as SubscriptionWithRoom[]; }, [serverResults, localRooms, name]); From bd23841651f52c1aadf20d7541d67b6b3b7671d2 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 16:52:27 -0300 Subject: [PATCH 5/6] fix duplicated results --- .../NavBarSearch/hooks/useSearchItems.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index 027972bba857f..8be1f3d2ae393 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -72,15 +72,6 @@ export const useSearchItems = (filterText: string): { items: SubscriptionWithRoo const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => index === arr.findIndex((user) => _id === user._id); - const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => - !localRooms.find( - (item) => - (room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || - [item.rid, item._id].includes(room._id), - ); - const usersFilter = (user: { _id: string }): boolean => - !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); - const userMap = (user: { _id: string; name: string; @@ -110,9 +101,11 @@ export const useSearchItems = (filterText: string): { items: SubscriptionWithRoo uids?: string[] | undefined; }[]; + // Local subscriptions are deduped reactively in the merge below (against the *current* + // localRooms), so server results aren't filtered against them here. const resultsFromServer: resultsFromServerType = []; - resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap)); - resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); + resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).map(userMap)); + resultsFromServer.push(...spotlight.rooms); return resultsFromServer; }, @@ -132,7 +125,19 @@ export const useSearchItems = (filterText: string): { items: SubscriptionWithRoo const matchesFilter = ({ name, fname }: { name?: string; fname?: string }) => (name && filterRegex.test(name)) || (fname && filterRegex.test(fname)); - const fromServer = (serverResults ?? []).filter(matchesFilter); + // Single source of truth for local/server dedup: drop any server result already present as a + // local subscription, checked against the *current* localRooms. The query isn't keyed on + // localRooms (to avoid refetching on every subscription change), so subscriptions that load + // in after the fetch would otherwise render twice — once from localRooms, once from server. + const isLocalDuplicate = (item: { _id: string; t?: string; uids?: string[] }): boolean => + localRooms.some((room) => { + const sameRoom = [room.rid, room._id].includes(item._id); + const sameGroupDM = item.t === 'd' && !!item.uids && item.uids.length > 1 && item.uids.includes(room._id); + const sameDirectDM = item.t === 'd' && room.t === 'd' && !!room.uids && room.uids.length === 2 && room.uids.includes(item._id); + return sameRoom || sameGroupDM || sameDirectDM; + }); + + const fromServer = (serverResults ?? []).filter((item) => matchesFilter(item) && !isLocalDuplicate(item)); const exact = fromServer.filter((item) => [item.name, item.fname].includes(name)); return Array.from(new Set([...exact, ...localRooms, ...fromServer])) as SubscriptionWithRoom[]; }, [serverResults, localRooms, name]); From 7375939532f55a311c4e6ab0677bf7d7d43d36d9 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 16 Jun 2026 22:48:34 -0300 Subject: [PATCH 6/6] small fixes --- .../client/components/ResultsLiveRegion.tsx | 24 +++++++++++++++---- .../NavBarSearch/NavBarSearchListbox.tsx | 4 +--- .../NavBarSearch/hooks/useSearchItems.ts | 18 +++++++++++--- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/meteor/client/components/ResultsLiveRegion.tsx b/apps/meteor/client/components/ResultsLiveRegion.tsx index 3df599c66a509..7fc92094fb503 100644 --- a/apps/meteor/client/components/ResultsLiveRegion.tsx +++ b/apps/meteor/client/components/ResultsLiveRegion.tsx @@ -1,14 +1,28 @@ import { VisuallyHidden } from '@react-aria/visually-hidden'; import { useTranslation } from 'react-i18next'; -const ResultsLiveRegion = ({ shouldAnnounce, itemCount }: { shouldAnnounce: boolean; itemCount: number }) => { +const ResultsLiveRegion = ({ + shouldAnnounce, + itemCount, + isLoading = false, +}: { + shouldAnnounce: boolean; + itemCount: number; + isLoading?: boolean; +}) => { const { t } = useTranslation(); - if (itemCount === 0) { - return {shouldAnnounce && t('No_results_found')}; - } + const message = (() => { + if (isLoading) { + return t('Loading'); + } + if (!shouldAnnounce) { + return ''; + } + return itemCount === 0 ? t('No_results_found') : t('__count__result_found', { count: itemCount }); + })(); - return {shouldAnnounce && t('__count__result_found', { count: itemCount })}; + return {message}; }; export default ResultsLiveRegion; diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index 10dc8ebcfcad9..5e02cd8412d86 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -1,5 +1,4 @@ import type { OverlayTriggerAria } from '@react-aria/overlays'; -import { VisuallyHidden } from '@react-aria/visually-hidden'; import type { OverlayTriggerState } from '@react-stately/overlays'; import { Box, Tile } from '@rocket.chat/fuselage'; import { useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks'; @@ -51,8 +50,7 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) width='100%' flexDirection='column' > - - {isLoading ? t('Loading') : ''} +
{items.length === 0 && !isLoading && } diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index 8be1f3d2ae393..1804e054a95ff 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -55,7 +55,11 @@ export const useSearchItems = (filterText: string): { items: SubscriptionWithRoo const getSpotlight = useEndpoint('GET', '/v1/spotlight'); - const { data: serverResults, isFetching } = useQuery({ + const { + data: serverResults, + isFetching, + isPlaceholderData, + } = useQuery({ // Keyed on the debounced term only, so typing doesn't refetch on every keystroke. queryKey: ['sidebar/search/spotlight', debouncedName, mention, type], @@ -137,10 +141,18 @@ export const useSearchItems = (filterText: string): { items: SubscriptionWithRoo return sameRoom || sameGroupDM || sameDirectDM; }); - const fromServer = (serverResults ?? []).filter((item) => matchesFilter(item) && !isLocalDuplicate(item)); + // When local subscriptions already fill the limit the server query is disabled, but React Query + // keeps the last results around — ignore them so a full local list isn't padded with stale rows. + const candidates = localRooms.length < LIMIT ? (serverResults ?? []) : []; + const fromServer = candidates.filter((item) => matchesFilter(item) && !isLocalDuplicate(item)); const exact = fromServer.filter((item) => [item.name, item.fname].includes(name)); return Array.from(new Set([...exact, ...localRooms, ...fromServer])) as SubscriptionWithRoom[]; }, [serverResults, localRooms, name]); - return { items, isLoading: isFetching }; + // `isFetching` is also true for silent background revalidations (after staleTime) of results we + // already show — only surface loading while there's no usable data for the current term yet, i.e. + // the very first fetch (serverResults undefined) or a new term still showing placeholder data. + const isLoading = isFetching && (isPlaceholderData || serverResults === undefined); + + return { items, isLoading }; };