Files
fusion/packages/dashboard/app/hooks/useFavorites.ts
Fusion 1d0b872914 fix(FN-2573): prevent stale-closure writes in useFavorites
- Refactor useFavorites callbacks to read latest state instead of closing over stale favorites values
- Ensure add/remove/toggle flows update local state and persistence from a consistent source of truth
- Add regression tests that reproduce stale-closure timing scenarios and verify favorites remain correct
- Expand hook test coverage for rapid consecutive updates to guard against future closure regressions
2026-04-25 20:32:37 -07:00

99 lines
3.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
/**
* Favorite model/provider state and actions consumed by the dashboard App shell.
*/
export interface UseFavoritesResult {
availableModels: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
toggleFavoriteProvider: (provider: string) => Promise<void>;
toggleFavoriteModel: (modelId: string) => Promise<void>;
}
/**
* Loads model catalog + favorites and exposes optimistic favorite toggles.
*/
export function useFavorites(): UseFavoritesResult {
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const favoriteProvidersRef = useRef<string[]>(favoriteProviders);
const favoriteModelsRef = useRef<string[]>(favoriteModels);
useEffect(() => {
fetchModels()
.then((response) => {
setAvailableModels(response.models);
favoriteProvidersRef.current = response.favoriteProviders;
favoriteModelsRef.current = response.favoriteModels;
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
})
.catch(() => {
// Keep defaults on fetch failure.
});
}, []);
useEffect(() => {
favoriteProvidersRef.current = favoriteProviders;
}, [favoriteProviders]);
useEffect(() => {
favoriteModelsRef.current = favoriteModels;
}, [favoriteModels]);
const toggleFavoriteProvider = useCallback(async (provider: string) => {
const previousFavorites = favoriteProvidersRef.current;
const isFavorite = previousFavorites.includes(provider);
const nextFavorites = isFavorite
? previousFavorites.filter((p) => p !== provider)
: [provider, ...previousFavorites];
favoriteProvidersRef.current = nextFavorites;
setFavoriteProviders(() => nextFavorites);
try {
await updateGlobalSettings({
favoriteProviders: nextFavorites,
favoriteModels: favoriteModelsRef.current,
});
} catch (error) {
favoriteProvidersRef.current = previousFavorites;
setFavoriteProviders(() => previousFavorites);
throw error;
}
}, []);
const toggleFavoriteModel = useCallback(async (modelId: string) => {
const previousFavorites = favoriteModelsRef.current;
const isFavorite = previousFavorites.includes(modelId);
const nextFavorites = isFavorite
? previousFavorites.filter((id) => id !== modelId)
: [modelId, ...previousFavorites];
favoriteModelsRef.current = nextFavorites;
setFavoriteModels(() => nextFavorites);
try {
await updateGlobalSettings({
favoriteProviders: favoriteProvidersRef.current,
favoriteModels: nextFavorites,
});
} catch (error) {
favoriteModelsRef.current = previousFavorites;
setFavoriteModels(() => previousFavorites);
throw error;
}
}, []);
return {
availableModels,
favoriteProviders,
favoriteModels,
toggleFavoriteProvider,
toggleFavoriteModel,
};
}