🔄 fix: URL Param Race Condition and File Draft Persistence (#7257)

* chore(useAutoSave): linting

* fix: files attached during streaming disappear when stream finishes

* fix(useQueryParams): query parameter processing race condition with submission handling, add JSDocs to all functions/hooks

* test(useQueryParams): add comprehensive tests for query parameter handling and submission logic
This commit is contained in:
Danny Avila 2025-05-06 22:49:12 -04:00 committed by GitHub
parent 20c9f1a783
commit 7c4c3a8796
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 675 additions and 16 deletions

View file

@ -17,6 +17,10 @@ import { useChatContext, useChatFormContext } from '~/Providers';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import store from '~/store';
/**
* Parses query parameter values, converting strings to their appropriate types.
* Handles boolean strings, numbers, and preserves regular strings.
*/
const parseQueryValue = (value: string) => {
if (value === 'true') {
return true;
@ -30,6 +34,11 @@ const parseQueryValue = (value: string) => {
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.
*/
const processValidSettings = (queryParams: Record<string, string>) => {
const validSettings = {} as TPreset;
@ -64,6 +73,11 @@ const processValidSettings = (queryParams: Record<string, string>) => {
return validSettings;
};
/**
* Hook that processes URL query parameters to initialize chat with specified settings and prompt.
* Handles model switching, prompt auto-filling, and optional auto-submission with race condition protection.
* Supports immediate or deferred submission based on whether settings need to be applied first.
*/
export default function useQueryParams({
textAreaRef,
}: {
@ -71,7 +85,15 @@ export default function useQueryParams({
}) {
const maxAttempts = 50;
const attemptsRef = useRef(0);
const MAX_SETTINGS_WAIT_MS = 3000;
const processedRef = useRef(false);
const pendingSubmitRef = useRef(false);
const settingsAppliedRef = useRef(false);
const submissionHandledRef = useRef(false);
const promptTextRef = useRef<string | null>(null);
const validSettingsRef = useRef<TPreset | null>(null);
const settingsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const methods = useChatFormContext();
const [searchParams] = useSearchParams();
const getDefaultConversation = useDefaultConvo();
@ -82,6 +104,11 @@ export default function useQueryParams({
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
/**
* Applies settings from URL query parameters to create a new conversation.
* Handles model spec lookup, endpoint normalization, and conversation switching logic.
* Ensures tools compatibility and preserves existing conversation when appropriate.
*/
const newQueryConvo = useCallback(
(_newPreset?: TPreset) => {
if (!_newPreset) {
@ -181,6 +208,85 @@ export default function useQueryParams({
],
);
/**
* Checks if all settings from URL parameters have been successfully applied to the conversation.
* Compares values from validSettings against the current conversation state, handling special properties.
* Returns true only when all relevant settings match the target values.
*/
const areSettingsApplied = useCallback(() => {
if (!validSettingsRef.current || !conversation) {
return false;
}
for (const [key, value] of Object.entries(validSettingsRef.current)) {
if (['presetOverride', 'iconURL', 'spec', 'modelLabel'].includes(key)) {
continue;
}
if (conversation[key] !== value) {
return false;
}
}
return true;
}, [conversation]);
/**
* Processes message submission exactly once, preventing duplicate submissions.
* Sets the prompt text, submits the message, and cleans up URL parameters afterward.
* Has internal guards to ensure it only executes once regardless of how many times it's called.
*/
const processSubmission = useCallback(() => {
if (submissionHandledRef.current || !pendingSubmitRef.current || !promptTextRef.current) {
return;
}
submissionHandledRef.current = true;
pendingSubmitRef.current = false;
methods.setValue('text', promptTextRef.current, { shouldValidate: true });
methods.handleSubmit((data) => {
if (data.text?.trim()) {
submitMessage(data);
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
console.log('Message submitted with conversation state:', conversation);
}
})();
}, [methods, submitMessage, conversation]);
useEffect(() => {
// Only proceed if we've already processed URL parameters but haven't yet handled submission
if (
!processedRef.current ||
submissionHandledRef.current ||
settingsAppliedRef.current ||
!validSettingsRef.current ||
!conversation
) {
return;
}
const allSettingsApplied = areSettingsApplied();
if (allSettingsApplied) {
settingsAppliedRef.current = true;
if (pendingSubmitRef.current) {
if (settingsTimeoutRef.current) {
clearTimeout(settingsTimeoutRef.current);
settingsTimeoutRef.current = null;
}
console.log('Settings fully applied, processing submission');
processSubmission();
}
}
}, [conversation, processSubmission, areSettingsApplied]);
useEffect(() => {
const processQueryParams = () => {
const queryParams: Record<string, string> = {};
@ -217,31 +323,68 @@ export default function useQueryParams({
if (!startupConfig) {
return;
}
const { decodedPrompt, validSettings, shouldAutoSubmit } = processQueryParams();
const currentText = methods.getValues('text');
/** Clean up URL parameters after successful processing */
const { decodedPrompt, validSettings, shouldAutoSubmit } = processQueryParams();
if (!shouldAutoSubmit) {
submissionHandledRef.current = true;
}
/** Mark processing as complete and clean up as needed */
const success = () => {
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
processedRef.current = true;
console.log('Parameters processed successfully');
clearInterval(intervalId);
// Only clean URL if there's no pending submission
if (!pendingSubmitRef.current) {
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
};
if (!currentText && decodedPrompt) {
methods.setValue('text', decodedPrompt, { shouldValidate: true });
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(decodedPrompt.length, decodedPrompt.length);
// Store settings for later comparison
if (Object.keys(validSettings).length > 0) {
validSettingsRef.current = validSettings;
}
// Save the prompt text for later use if needed
if (decodedPrompt) {
promptTextRef.current = decodedPrompt;
}
// Handle auto-submission
if (shouldAutoSubmit && decodedPrompt) {
if (Object.keys(validSettings).length > 0) {
// Settings are changing, defer submission
pendingSubmitRef.current = true;
// Set a timeout to handle the case where settings might never fully apply
settingsTimeoutRef.current = setTimeout(() => {
if (!submissionHandledRef.current && pendingSubmitRef.current) {
console.warn(
'Settings application timeout reached, proceeding with submission anyway',
);
processSubmission();
}
}, MAX_SETTINGS_WAIT_MS);
} else {
methods.setValue('text', decodedPrompt, { shouldValidate: true });
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(decodedPrompt.length, decodedPrompt.length);
// Auto-submit if the submit parameter is true
if (shouldAutoSubmit) {
methods.handleSubmit((data) => {
if (data.text?.trim()) {
submitMessage(data);
}
})();
}
} else if (decodedPrompt) {
methods.setValue('text', decodedPrompt, { shouldValidate: true });
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(decodedPrompt.length, decodedPrompt.length);
} else {
submissionHandledRef.current = true;
}
if (Object.keys(validSettings).length > 0) {
@ -253,6 +396,18 @@ export default function useQueryParams({
return () => {
clearInterval(intervalId);
if (settingsTimeoutRef.current) {
clearTimeout(settingsTimeoutRef.current);
}
};
}, [searchParams, methods, textAreaRef, newQueryConvo, newConversation, submitMessage]);
}, [
searchParams,
methods,
textAreaRef,
newQueryConvo,
newConversation,
submitMessage,
queryClient,
processSubmission,
]);
}