🔑 fix: Auth-Aware Startup Config Caching for Fresh Sessions (#12505)

* fix: auth-aware config caching for fresh sessions

- Add auth state to startup config query key via shared `startupConfigKey`
  builder so login (unauthenticated) and chat (authenticated) configs are
  cached independently
- Disable queries during login onMutate to prevent premature unauthenticated
  refetches after cache clear
- Re-enable queries in setUserContext only after setTokenHeader runs, with
  positive-only guard to avoid redundant disable on logout
- Update all getQueryData call sites to use the shared key builder
- Fall back to getConfigDefaults().interface in useEndpoints, hoisted to
  module-level constant to avoid per-render recomputation

* fix: address review findings for auth-aware config caching

- Move defaultInterface const after all imports in ModelSelector.tsx
- Remove dead QueryKeys import, use import type for TStartupConfig
  in ImportConversations.tsx
- Spread real exports in useQueryParams.spec.ts mock to preserve
  startupConfigKey, fixing TypeError in all 6 tests

* chore: import order

* fix: re-enable queries on login failure

When login fails, onSuccess never fires so queriesEnabled stays
false. Re-enable in onError so the login page can re-fetch config
(needed for LDAP username validation and social login options).
This commit is contained in:
Danny Avila 2026-04-01 17:20:39 -04:00 committed by GitHub
parent c4b5dedb77
commit 7b368916d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 59 additions and 30 deletions

View file

@ -15,6 +15,8 @@ import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager';
import { useLocalize } from '~/hooks';
const defaultInterface = getConfigDefaults().interface;
function ModelSelectorContent() {
const localize = useLocalize();
@ -122,7 +124,7 @@ function ModelSelectorContent() {
}
export default function ModelSelector({ startupConfig }: ModelSelectorProps) {
const interfaceConfig = startupConfig?.interface ?? getConfigDefaults().interface;
const interfaceConfig = startupConfig?.interface ?? defaultInterface;
const modelSpecs = startupConfig?.modelSpecs?.list ?? [];
// Hide the selector when modelSelect is false and there are no model specs to show

View file

@ -1,9 +1,9 @@
import { useState, useRef, useCallback } from 'react';
import { Import } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, TStartupConfig } from 'librechat-data-provider';
import type { TStartupConfig } from 'librechat-data-provider';
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
import { useUploadConversationsMutation } from '~/data-provider';
import { startupConfigKey, useUploadConversationsMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
@ -51,7 +51,7 @@ function ImportConversations() {
const handleFileUpload = useCallback(
async (file: File) => {
try {
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const startupConfig = queryClient.getQueryData<TStartupConfig>(startupConfigKey(true));
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
if (maxFileSize && file.size > maxFileSize) {
const size = (maxFileSize / (1024 * 1024)).toFixed(2);

View file

@ -40,15 +40,20 @@ export const useLoginUserMutation = (
mutationFn: (payload: t.TLoginUser) => dataService.login(payload),
...(options || {}),
onMutate: (vars) => {
setQueriesEnabled(false);
resetDefaultPreset();
clearStates();
queryClient.removeQueries();
options?.onMutate?.(vars);
},
// Queries re-enabled in setUserContext (AuthContext) after setTokenHeader runs
onSuccess: (...args) => {
setQueriesEnabled(true);
options?.onSuccess?.(...args);
},
onError: (...args) => {
setQueriesEnabled(true);
options?.onError?.(...args);
},
});
};

View file

@ -23,12 +23,21 @@ export const useGetEndpointsQuery = <TData = t.TEndpointsConfig>(
);
};
/**
* Auth-aware query key so unauthenticated (login page) and authenticated
* (chat page) configs are cached independently, preventing stale
* unauthenticated config from persisting after login.
*/
export const startupConfigKey = (isAuthenticated: boolean) =>
[QueryKeys.startupConfig, isAuthenticated] as const;
export const useGetStartupConfig = (
config?: UseQueryOptions<t.TStartupConfig>,
): QueryObserverResult<t.TStartupConfig> => {
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
const user = useRecoilValue<t.TUser | undefined>(store.user);
return useQuery<t.TStartupConfig>(
[QueryKeys.startupConfig],
startupConfigKey(!!user),
() => dataService.getStartupConfig(),
{
staleTime: Infinity,

View file

@ -8,7 +8,7 @@ import {
createContext,
} from 'react';
import { debounce } from 'lodash';
import { useRecoilState } from 'recoil';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import {
apiBaseUrl,
@ -45,6 +45,7 @@ const AuthContextProvider = ({
const [token, setToken] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const setQueriesEnabled = useSetRecoilState<boolean>(store.queriesEnabled);
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
enabled: !!(isAuthenticated && (user?.role ?? '')),
@ -63,6 +64,9 @@ const AuthContextProvider = ({
setToken(token);
setTokenHeader(token);
setIsAuthenticated(isAuthenticated);
if (isAuthenticated) {
setQueriesEnabled(true);
}
const searchParams = new URLSearchParams(window.location.search);
const postLoginRedirect = getPostLoginRedirect(searchParams);
@ -81,7 +85,7 @@ const AuthContextProvider = ({
navigate(finalRedirect, { replace: true });
}, 50),
[navigate, setUser],
[navigate, setUser, setQueriesEnabled],
);
const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) });

View file

@ -30,6 +30,7 @@ import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import { logger, createDualMessageContent } from '~/utils';
import store, { useGetEphemeralAgent } from '~/store';
import { startupConfigKey } from '~/data-provider';
import useUserKey from '~/hooks/Input/useUserKey';
import { useAuthContext } from '~/hooks';
@ -172,7 +173,7 @@ export default function useChatFunctions({
}
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const startupConfig = queryClient.getQueryData<TStartupConfig>(startupConfigKey(true));
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const iconURL = conversation?.iconURL;
const defaultParamsEndpoint = getDefaultParamsEndpoint(endpointsConfig, endpoint);

View file

@ -23,6 +23,7 @@ import {
logger,
} from '~/utils';
import { useApplyModelSpecEffects } from '~/hooks/Agents';
import { startupConfigKey } from '~/data-provider';
import store from '~/store';
const useNavigateToConvo = (index = 0) => {
@ -41,7 +42,7 @@ const useNavigateToConvo = (index = 0) => {
return;
}
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const startupConfig = queryClient.getQueryData<TStartupConfig>(startupConfigKey(true));
applyModelSpecEffects({
startupConfig,
specName: conversation?.spec,

View file

@ -6,6 +6,7 @@ import {
EModelEndpoint,
PermissionTypes,
getEndpointField,
getConfigDefaults,
} from 'librechat-data-provider';
import type {
TEndpointsConfig,
@ -20,6 +21,8 @@ import { mapEndpoints, getIconKey } from '~/utils';
import { useHasAccess } from '~/hooks';
import { icons } from './Icons';
const defaultInterface = getConfigDefaults().interface;
export const useEndpoints = ({
agents,
assistantsMap,
@ -33,7 +36,7 @@ export const useEndpoints = ({
}) => {
const modelsQuery = useGetModelsQuery();
const { data: endpoints = [] } = useGetEndpointsQuery({ select: mapEndpoints });
const interfaceConfig = startupConfig?.interface ?? {};
const interfaceConfig = startupConfig?.interface ?? defaultInterface;
const includedEndpoints = useMemo(
() => new Set(startupConfig?.modelSpecs?.addedEndpoints ?? []),
[startupConfig?.modelSpecs?.addedEndpoints],

View file

@ -93,19 +93,23 @@ jest.mock('librechat-data-provider', () => {
};
});
// Mock data-provider hooks
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
useListAgentsQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
}));
// Mock data-provider hooks while preserving real exports like startupConfigKey
jest.mock('~/data-provider', () => {
const actual = jest.requireActual<typeof import('~/data-provider')>('~/data-provider');
return {
...actual,
useGetAgentByIdQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
useListAgentsQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
};
});
// Mock global window.history
global.window = Object.create(window);

View file

@ -19,8 +19,8 @@ import {
logger,
} from '~/utils';
import { useAuthContext, useAgentsMap, useDefaultConvo, useSubmitMessage } from '~/hooks';
import { startupConfigKey, useGetAgentByIdQuery } from '~/data-provider';
import { useChatContext, useChatFormContext } from '~/Providers';
import { useGetAgentByIdQuery } from '~/data-provider';
import store from '~/store';
const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => {
@ -84,7 +84,7 @@ export default function useQueryParams({
}
let newPreset = removeUnavailableTools(_newPreset, availableTools);
if (newPreset.spec != null && newPreset.spec !== '') {
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const startupConfig = queryClient.getQueryData<TStartupConfig>(startupConfigKey(true));
const modelSpecs = startupConfig?.modelSpecs?.list ?? [];
const spec = modelSpecs.find((s) => s.name === newPreset.spec);
if (!spec) {
@ -258,7 +258,7 @@ export default function useQueryParams({
if (!textAreaRef.current) {
return;
}
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const startupConfig = queryClient.getQueryData<TStartupConfig>(startupConfigKey(true));
if (!startupConfig) {
return;
}

View file

@ -33,7 +33,7 @@ import {
removeConvoFromAllQueries,
findConversationInInfinite,
} from '~/utils';
import { queueTitleGeneration } from '~/data-provider/SSE/queries';
import { startupConfigKey, queueTitleGeneration } from '~/data-provider';
import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
import useContentHandler from '~/hooks/SSE/useContentHandler';
import useStepHandler from '~/hooks/SSE/useStepHandler';
@ -406,7 +406,7 @@ export default function useEventHandlers({
sourceId: submission.conversation?.conversationId,
ephemeralAgent: submission.ephemeralAgent,
specName: submission.conversation?.spec,
startupConfig: queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]),
startupConfig: queryClient.getQueryData<TStartupConfig>(startupConfigKey(true)),
});
}
@ -588,7 +588,7 @@ export default function useEventHandlers({
sourceId: submissionConvo.conversationId,
ephemeralAgent: submission.ephemeralAgent,
specName: submission.conversation?.spec,
startupConfig: queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]),
startupConfig: queryClient.getQueryData<TStartupConfig>(startupConfigKey(true)),
});
}