LibreChat/client/src/utils/createChatSearchParams.ts
Danny Avila b1771e0a6e
🌐 fix: Preserve URL Query Params Through Auth Refresh and Conversation Init (#12028)
* 🔗 fix: Preserve URL query params during silent token refresh

The silent token refresh on hard navigation was redirecting to '/c/new'
without query params, wiping the URL before ChatRoute could read them.
Now preserves the current URL (pathname + search) as the redirect
fallback, with isSafeRedirect validation.

* 🧭 fix: Apply URL query params in ChatRoute initialization

ChatRoute now reads URL search params (endpoint, model, agent_id, etc.)
and merges them into the preset passed to newConversation(), so the
first conversation init already includes the URL param settings. This
eliminates the race where useQueryParams fired too late.

- Export processValidSettings from useQueryParams for reuse
- Add getNewConvoPreset helper in ChatRoute (used in both NEW_CONVO branches)
- Query params take precedence over model spec defaults
- useQueryParams now waits for endpointsConfig before processing
- Skip redundant newQueryConvo when settings are already applied
- Clean all URL params via setSearchParams after processing

*  test: Update useQueryParams tests for new URL cleanup behavior

- Assert setSearchParams called instead of window.history.replaceState
- Mock endpoints config in deferred submission and timeout tests

* ♻️ refactor: Move processValidSettings to ~/utils and address review findings

- Move processValidSettings/parseQueryValue to createChatSearchParams.ts
  (pure utility, not hook-specific)
- Fix processSubmission: use setSearchParams instead of replaceState,
  move URL cleanup outside data.text guard
- Narrow endpointsConfig guard: only block settings application, not
  prompt-only flows
- Convert areSettingsApplied to stable useCallback ([] deps) with
  conversationRef to avoid interval churn on conversation updates
- Replace console.log with logger.log in production paths
- Restore explanatory comment on pendingSubmitRef guard
- Use for...of in processValidSettings (CLAUDE.md preference)
- Remove unused imports from useQueryParams

* 🔧 fix: Add areSettingsApplied to effect deps and fix test mocks

- Restore areSettingsApplied in main effect deps (stable identity with
  [] deps, safe to include — satisfies exhaustive-deps lint rule)
- Fix all test getQueryData mocks to properly distinguish between
  startupConfig and endpoints keys
- Assert setSearchParams call arguments (URLSearchParams + replace:true)

*  test: Assert empty URLSearchParams in setSearchParams calls

Tighten setSearchParams assertions to verify the params are empty
(toString() === ''), not just that a URLSearchParams instance was passed.

* 🔧 test: Update AuthContext tests to navigate to current URL for redirects

- Modified test cases to assert navigation to the current URL instead of a hardcoded '/c/new' when no stored redirect exists or when falling back from unsafe stored redirects.
- Enhanced test setup to define window.location for accurate simulation of redirect behavior.
2026-03-02 23:32:53 -05:00

129 lines
3.6 KiB
TypeScript

import {
EModelEndpoint,
isAgentsEndpoint,
tQueryParamsSchema,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TPreset, TConversation } from 'librechat-data-provider';
import type { ZodAny } from 'zod';
import { isEphemeralAgent } from '~/common';
const parseQueryValue = (value: string) => {
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
if (!isNaN(Number(value))) {
return Number(value);
}
return value;
};
/**
* Processes and validates URL query parameters using schema definitions.
* Extracts valid settings based on tQueryParamsSchema and handles special endpoint cases
* for assistants and agents.
*/
export function processValidSettings(queryParams: Record<string, string>) {
const validSettings = {} as TPreset;
for (const [key, value] of Object.entries(queryParams)) {
try {
const schema = tQueryParamsSchema.shape[key] as ZodAny | undefined;
if (schema) {
const parsedValue = parseQueryValue(value);
const validValue = schema.parse(parsedValue);
validSettings[key] = validValue;
}
} catch (error) {
console.warn(`Invalid value for setting ${key}:`, error);
}
}
if (
validSettings.assistant_id != null &&
validSettings.assistant_id &&
!isAssistantsEndpoint(validSettings.endpoint)
) {
validSettings.endpoint = EModelEndpoint.assistants;
}
if (
validSettings.agent_id != null &&
validSettings.agent_id &&
!isAgentsEndpoint(validSettings.endpoint)
) {
validSettings.endpoint = EModelEndpoint.agents;
}
return validSettings;
}
const allowedParams = Object.keys(tQueryParamsSchema.shape);
export default function createChatSearchParams(
input: TConversation | TPreset | Record<string, string> | null,
): URLSearchParams {
if (input == null) {
return new URLSearchParams();
}
const params = new URLSearchParams();
if (input && typeof input === 'object' && !('endpoint' in input) && !('model' in input)) {
Object.entries(input as Record<string, string>).forEach(([key, value]) => {
if (value != null && allowedParams.includes(key)) {
params.set(key, value);
}
});
return params;
}
const conversation = input as TConversation | TPreset;
const endpoint = conversation.endpoint;
if (conversation.spec) {
return new URLSearchParams({ spec: conversation.spec });
}
if (
isAgentsEndpoint(endpoint) &&
conversation.agent_id &&
!isEphemeralAgent(conversation.agent_id)
) {
return new URLSearchParams({ agent_id: String(conversation.agent_id) });
} else if (isAssistantsEndpoint(endpoint) && conversation.assistant_id) {
return new URLSearchParams({ assistant_id: String(conversation.assistant_id) });
} else if (isAgentsEndpoint(endpoint) && !conversation.agent_id) {
return params;
} else if (isAssistantsEndpoint(endpoint) && !conversation.assistant_id) {
return params;
}
if (endpoint) {
params.set('endpoint', endpoint);
}
if (conversation.model) {
params.set('model', conversation.model);
}
const paramMap: Record<string, any> = {};
allowedParams.forEach((key) => {
if (key === 'agent_id' && isEphemeralAgent(conversation.agent_id)) {
return;
}
if (key !== 'endpoint' && key !== 'model') {
paramMap[key] = (conversation as any)[key];
}
});
return Object.entries(paramMap).reduce((params, [key, value]) => {
if (value != null) {
if (Array.isArray(value)) {
params.set(key, key === 'stop' ? value.join(',') : JSON.stringify(value));
} else {
params.set(key, String(value));
}
}
return params;
}, params);
}