| @@ -1,4 +1,4 @@ |
| 1 | | −import { useDeferredValue, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; |
| 1 | +import { useDeferredValue, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; |
| 2 | 2 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; |
| 3 | 3 | import { useForm, useWatch } from "react-hook-form"; |
| 4 | 4 | import { fetchAndInspectOpenApiSpec, type InferredSpecAuth } from "@/lib/openapi/spec-inspector"; |
| @@ -117,6 +117,11 @@ export function useAddSourceFormState({ · 112 unmodified lines |
| 117 | 117 | const values: SourceFormValues = { ...createDefaultFormValues(), ...watched }; |
| 118 | 118 | const [ui, setUi] = useState<AddSourceUiState>(() => createDefaultUiState(sourceToEdit ? "custom" : "catalog")); |
| 119 | 119 | |
| 120 | + // Refs to let effects read current values without adding reactive deps that cause loops. |
| 121 | + const authManuallyEditedRef = useRef(ui.authManuallyEdited); |
| 122 | + authManuallyEditedRef.current = ui.authManuallyEdited; |
| 123 | + const appliedDetectionRef = useRef<{ endpoint: string; type: SourceType } | null>(null); |
| 124 | + |
| 120 | 125 | const visibleCatalogItems = useMemo( |
| 121 | 126 | () => getVisibleCatalogItems(ui.catalogQuery, ui.catalogSort), |
| 122 | 127 | [ui.catalogQuery, ui.catalogSort], |
| @@ -160,6 +165,9 @@ export function useAddSourceFormState({ · 37 unmodified lines |
| 160 | 165 | return; |
| 161 | 166 | } |
| 162 | 167 | |
| 168 | + // Clear applied detection tracking when dialog opens/resets |
| 169 | + appliedDetectionRef.current = null; |
| 170 | + |
| 163 | 171 | if (sourceToEdit) { |
| 164 | 172 | const editValues = sourceToFormValues(sourceToEdit); |
| 165 | 173 | form.reset(editValues); |
| @@ -396,60 +404,60 @@ export function useAddSourceFormState({ · 230 unmodified lines |
| 396 | 404 | ? "error" |
| 397 | 405 | : "idle"; |
| 398 | 406 | |
| 399 | | − // Auto-set the type when detection succeeds |
| 407 | + // Auto-set the type when detection succeeds. |
| 408 | + // Uses a ref to track what we last applied, avoiding deps on values the effect itself mutates. |
| 400 | 409 | useEffect(() => { |
| 401 | 410 | if (!typeDetectionEnabled || !typeDetectionQuery.data) { |
| 402 | 411 | return; |
| 403 | 412 | } |
| 404 | 413 | const detected = typeDetectionQuery.data.type; |
| 405 | | − if (values.type !== detected) { |
| 406 | | − form.setValue("type", detected, { shouldDirty: false, shouldTouch: false }); |
| 407 | | − |
| 408 | | − // Trigger the same side-effects as handleTypeChange but without marking as explicitly set |
| 409 | | − if (detected === "openapi") { |
| 410 | | − const inferredBaseUrl = deriveBaseUrlFromEndpoint(values.endpoint); |
| 411 | | − patchUi(setUi, { |
| 412 | | − openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], |
| 413 | | − authManuallyEdited: false, |
| 414 | | − mcpOAuthLinkedEndpoint: null, |
| 415 | | − }); |
| 416 | | − if (!baseUrlManuallyEdited) { |
| 417 | | − form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); |
| 418 | | − } |
| 419 | | − } else { |
| 420 | | − patchUi(setUi, { |
| 421 | | − openApiBaseUrlOptions: [], |
| 422 | | − mcpOAuthLinkedEndpoint: null, |
| 423 | | − // Preserve authManuallyEdited — user may have set credentials before detection |
| 424 | | − }); |
| 425 | | − // Only reset auth fields if the user hasn't manually edited them |
| 426 | | − if (!ui.authManuallyEdited) { |
| 427 | | − patchUi(setUi, { authManuallyEdited: false }); |
| 428 | | − form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| 429 | | − form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| 430 | | − } |
| 414 | + |
| 415 | + // Skip if we already applied this exact detection result for this endpoint |
| 416 | + if ( |
| 417 | + appliedDetectionRef.current |
| 418 | + && appliedDetectionRef.current.endpoint === detectionEndpoint |
| 419 | + && appliedDetectionRef.current.type === detected |
| 420 | + ) { |
| 421 | + return; |
| 422 | + } |
| 423 | + |
| 424 | + appliedDetectionRef.current = { endpoint: detectionEndpoint, type: detected }; |
| 425 | + form.setValue("type", detected, { shouldDirty: false, shouldTouch: false }); |
| 426 | + |
| 427 | + // Trigger the same side-effects as handleTypeChange but without marking as explicitly set. |
| 428 | + // Uses functional setUi to read current state without depending on it reactively. |
| 429 | + if (detected === "openapi") { |
| 430 | + const inferredBaseUrl = deriveBaseUrlFromEndpoint(detectionEndpoint); |
| 431 | + setUi((current) => ({ |
| 432 | + ...current, |
| 433 | + openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], |
| 434 | + mcpOAuthLinkedEndpoint: null, |
| 435 | + })); |
| 436 | + if (!baseUrlManuallyEdited) { |
| 437 | + form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); |
| 438 | + } |
| 439 | + } else { |
| 440 | + setUi((current) => ({ |
| 441 | + ...current, |
| 442 | + openApiBaseUrlOptions: [], |
| 443 | + mcpOAuthLinkedEndpoint: null, |
| 444 | + })); |
| 445 | + // Only reset auth form values if the user hasn't manually set credentials. |
| 446 | + // Read from ref to avoid adding a reactive dep that causes render loops. |
| 447 | + if (!authManuallyEditedRef.current) { |
| 448 | + form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| 449 | + form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| 431 | 450 | } |
| 432 | 451 | } |
| 433 | 452 | |
| 434 | 453 | // Also infer name from URL if not already set |
| 435 | 454 | if (!nameManuallyEdited) { |
| 436 | 455 | const inferredName = inferNameFromUrl(detectionEndpoint); |
| 437 | | − if (inferredName && values.name !== inferredName) { |
| 456 | + if (inferredName) { |
| 438 | 457 | form.setValue("name", inferredName, { shouldDirty: false, shouldTouch: false }); |
| 439 | 458 | } |
| 440 | 459 | } |
| 441 | | − }, [ |
| 442 | | − typeDetectionEnabled, |
| 443 | | − typeDetectionQuery.data, |
| 444 | | − baseUrlManuallyEdited, |
| 445 | | − detectionEndpoint, |
| 446 | | − form, |
| 447 | | − nameManuallyEdited, |
| 448 | | − ui.authManuallyEdited, |
| 449 | | − values.endpoint, |
| 450 | | − values.name, |
| 451 | | − values.type, |
| 452 | | − ]); |
| 460 | + }, [typeDetectionEnabled, typeDetectionQuery.data, detectionEndpoint, form, baseUrlManuallyEdited, nameManuallyEdited]); |
| 453 | 461 | |
| 454 | 462 | // Type is "resolved" when explicitly set by the user/catalog, or auto-detected from the URL. |
| 455 | 463 | // Type-specific queries (spec inspection, MCP OAuth) should only fire after resolution, |