From 05ac524fd9c9242ce6f608f0f7a663cf18bc8d2c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:57:36 +0200 Subject: [PATCH] fix: refetch favorites when toggled before the list loads, lint fixes An optimistic favorite written over an unpopulated cache seeded the list with only the toggled item, and cancelQueries killed the initial fetch that would have corrected it, hiding existing favorites until reload. The optimistic write now only applies over known data; otherwise onSettled invalidates so the authoritative list is refetched. Also unnest the version date-label ternary and drop an unused form watch flagged by CI. --- .../SidePanel/Agents/Version/VersionItem.tsx | 11 +++--- .../SidePanel/Builder/ActionsPanel.tsx | 2 +- client/src/data-provider/Favorites.ts | 34 +++++++++++++++---- .../hooks/__tests__/useToolFavorites.spec.tsx | 19 +++++++++++ 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx index 1e36ef706a..407860caa8 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx @@ -53,11 +53,12 @@ export default function VersionItem({ const date = getTimestampDate(version); const hasUpdatedAt = version.updatedAt != null; const hasCreatedAt = version.createdAt != null; - const relativeLabel = date - ? formatDistanceToNow(date, { addSuffix: true }) - : hasUpdatedAt || hasCreatedAt - ? localize('com_ui_agent_version_unknown_date') - : localize('com_ui_agent_version_no_date'); + const fallbackDateLabel = localize( + hasUpdatedAt || hasCreatedAt + ? 'com_ui_agent_version_unknown_date' + : 'com_ui_agent_version_no_date', + ); + const relativeLabel = date ? formatDistanceToNow(date, { addSuffix: true }) : fallbackDateLabel; const absoluteLabel = date ? date.toLocaleString() : relativeLabel; const toolsCount = countItems(version.tools); diff --git a/client/src/components/SidePanel/Builder/ActionsPanel.tsx b/client/src/components/SidePanel/Builder/ActionsPanel.tsx index 9cf39a2a62..edb6837ca8 100644 --- a/client/src/components/SidePanel/Builder/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Builder/ActionsPanel.tsx @@ -71,7 +71,7 @@ export default function ActionsPanel({ }, }); - const { reset, watch } = methods; + const { reset } = methods; useEffect(() => { if (action?.metadata?.auth) { diff --git a/client/src/data-provider/Favorites.ts b/client/src/data-provider/Favorites.ts index 4bac514ab5..17b53513b5 100644 --- a/client/src/data-provider/Favorites.ts +++ b/client/src/data-provider/Favorites.ts @@ -62,13 +62,20 @@ export const useGetToolFavoritesQuery = ( export const useAddToolFavoriteMutation = () => { const queryClient = useQueryClient(); return useMutation((favorite: TToolFavorite) => dataService.addToolFavorite(favorite), { + /** Optimistic writes only apply over known server data. Before the list + * query has populated, seeding the cache from `[]` would make the toggled + * item look like the user's only favorite (and `cancelQueries` kills the + * initial fetch that would correct it) — so skip the write and let + * `onSettled` refetch the authoritative list instead. */ onMutate: async (favorite) => { await queryClient.cancelQueries([QueryKeys.toolFavorites]); const previous = queryClient.getQueryData([QueryKeys.toolFavorites]); - queryClient.setQueryData([QueryKeys.toolFavorites], (current) => { - const list = current ?? []; - return list.some((f) => sameFavorite(f, favorite)) ? list : [...list, favorite]; - }); + if (previous !== undefined) { + queryClient.setQueryData( + [QueryKeys.toolFavorites], + previous.some((f) => sameFavorite(f, favorite)) ? previous : [...previous, favorite], + ); + } return { previous }; }, onError: (_err, _favorite, context) => { @@ -76,6 +83,11 @@ export const useAddToolFavoriteMutation = () => { queryClient.setQueryData([QueryKeys.toolFavorites], context.previous); } }, + onSettled: (_data, _err, _favorite, context) => { + if (context?.previous === undefined) { + queryClient.invalidateQueries([QueryKeys.toolFavorites]); + } + }, }); }; @@ -85,9 +97,12 @@ export const useRemoveToolFavoriteMutation = () => { onMutate: async (favorite) => { await queryClient.cancelQueries([QueryKeys.toolFavorites]); const previous = queryClient.getQueryData([QueryKeys.toolFavorites]); - queryClient.setQueryData([QueryKeys.toolFavorites], (current) => - (current ?? []).filter((f) => !sameFavorite(f, favorite)), - ); + if (previous !== undefined) { + queryClient.setQueryData( + [QueryKeys.toolFavorites], + previous.filter((f) => !sameFavorite(f, favorite)), + ); + } return { previous }; }, onError: (_err, _favorite, context) => { @@ -95,5 +110,10 @@ export const useRemoveToolFavoriteMutation = () => { queryClient.setQueryData([QueryKeys.toolFavorites], context.previous); } }, + onSettled: (_data, _err, _favorite, context) => { + if (context?.previous === undefined) { + queryClient.invalidateQueries([QueryKeys.toolFavorites]); + } + }, }); }; diff --git a/client/src/hooks/__tests__/useToolFavorites.spec.tsx b/client/src/hooks/__tests__/useToolFavorites.spec.tsx index d2672bf2df..eb6ddf15ff 100644 --- a/client/src/hooks/__tests__/useToolFavorites.spec.tsx +++ b/client/src/hooks/__tests__/useToolFavorites.spec.tsx @@ -98,6 +98,25 @@ describe('useToolFavorites', () => { expect(result.current.isFavorite({ kind: 'action', id: 'a1' })).toBe(false); }); + test('starring before the list loads refetches instead of hiding existing favorites', async () => { + mockGetToolFavorites + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce([...seeded, { itemType: 'skill', itemId: 's1' }]); + + const { result } = renderHook(() => useToolFavorites(), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.toggle({ kind: 'skill', id: 's1' }); + }); + + await waitFor(() => + expect(result.current.favoriteKeys).toEqual( + new Set(['tool:dalle', 'mcp:everything', 'skill:s1']), + ), + ); + expect(mockGetToolFavorites).toHaveBeenCalledTimes(2); + }); + test('shows the item-worded cap toast and rolls back on rejection', async () => { mockAddToolFavorite.mockRejectedValue({ response: { data: { code: 'MAX_FAVORITES_EXCEEDED', limit: 100 } },