diff --git a/src/components/modals/ModelSearchDialog.tsx b/src/components/modals/ModelSearchDialog.tsx index 014a230c..4e642ac7 100644 --- a/src/components/modals/ModelSearchDialog.tsx +++ b/src/components/modals/ModelSearchDialog.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; -import { useWorkflowStore } from "@/store/workflowStore"; +import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore"; +import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; import { useReactFlow } from "@xyflow/react"; import { ProviderType, RecentModel } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; @@ -69,13 +70,14 @@ export function ModelSearchDialog({ initialCapabilityFilter, }: ModelSearchDialogProps) { const { - providerSettings, addNode, incrementModalCount, decrementModalCount, recentModels, trackModelUsage, } = useWorkflowStore(); + // Use stable selector for API keys to prevent unnecessary re-fetches + const { replicateApiKey, falApiKey } = useProviderApiKeys(); const { screenToFlowPosition } = useReactFlow(); // State @@ -92,7 +94,8 @@ export function ModelSearchDialog({ // Refs const searchInputRef = useRef(null); - const abortControllerRef = useRef(null); + // Track request version to ignore stale responses + const requestVersionRef = useRef(0); // Register modal with store useEffect(() => { @@ -119,11 +122,8 @@ export function ModelSearchDialog({ // Fetch models const fetchModels = useCallback(async () => { - // Cancel previous request - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); + // Increment version to track this request + const thisVersion = ++requestVersionRef.current; setIsLoading(true); setError(null); @@ -147,21 +147,22 @@ export function ModelSearchDialog({ // Build headers with API keys const headers: Record = {}; - const replicateKey = providerSettings.providers.replicate?.apiKey; - const falKey = providerSettings.providers.fal?.apiKey; - - if (replicateKey) { - headers["X-Replicate-Key"] = replicateKey; + if (replicateApiKey) { + headers["X-Replicate-Key"] = replicateApiKey; } - if (falKey) { - headers["X-Fal-Key"] = falKey; + if (falApiKey) { + headers["X-Fal-Key"] = falApiKey; } - const response = await fetch(`/api/models?${params.toString()}`, { + const response = await deduplicatedFetch(`/api/models?${params.toString()}`, { headers, - signal: abortControllerRef.current.signal, }); + // Check if this request is still current + if (thisVersion !== requestVersionRef.current) { + return; // Ignore stale response + } + const data: ModelsResponse = await response.json(); if (data.success && data.models) { @@ -171,15 +172,19 @@ export function ModelSearchDialog({ setModels([]); } } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - return; // Ignore aborted requests + // Check if this request is still current + if (thisVersion !== requestVersionRef.current) { + return; // Ignore stale error } setError(err instanceof Error ? err.message : "Failed to fetch models"); setModels([]); } finally { - setIsLoading(false); + // Only update loading state if this is still the current request + if (thisVersion === requestVersionRef.current) { + setIsLoading(false); + } } - }, [debouncedSearch, providerFilter, capabilityFilter, providerSettings]); + }, [debouncedSearch, providerFilter, capabilityFilter, replicateApiKey, falApiKey]); // Fetch models when filters change useEffect(() => { diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 11deaf31..ca891b59 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -5,7 +5,8 @@ import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { ModelParameters } from "./ModelParameters"; -import { useWorkflowStore, saveNanoBananaDefaults } from "@/store/workflowStore"; +import { useWorkflowStore, saveNanoBananaDefaults, useProviderApiKeys } from "@/store/workflowStore"; +import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, SelectedModel, ModelInputDef } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; @@ -63,7 +64,8 @@ export function GenerateImageNode({ id, data, selected }: NodeProps state.updateNodeData); const generationsPath = useWorkflowStore((state) => state.generationsPath); - const providerSettings = useWorkflowStore((state) => state.providerSettings); + // Use stable selector for API keys to prevent unnecessary re-fetches + const { replicateApiKey, falApiKey, kieApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys(); const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false); const [externalModels, setExternalModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); @@ -81,24 +83,23 @@ export function GenerateImageNode({ id, data, selected }: NodeProps { - const hasReplicate = providerSettings.providers.replicate?.enabled && - providerSettings.providers.replicate?.apiKey; + const hasReplicate = replicateEnabled && replicateApiKey; // fal.ai is always available return !!(hasReplicate || true); - }, [providerSettings]); + }, [replicateEnabled, replicateApiKey]); const isGeminiOnly = !hasExternalProviders; @@ -128,16 +129,16 @@ export function GenerateImageNode({ id, data, selected }: NodeProps { fetchModels(); diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 0936e29e..1bff8ba9 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -5,7 +5,8 @@ import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { ModelParameters } from "./ModelParameters"; -import { useWorkflowStore } from "@/store/workflowStore"; +import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore"; +import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; import { GenerateVideoNodeData, ProviderType, SelectedModel, ModelInputDef } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; @@ -50,7 +51,8 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps state.updateNodeData); - const providerSettings = useWorkflowStore((state) => state.providerSettings); + // Use stable selector for API keys to prevent unnecessary re-fetches + const { replicateApiKey, falApiKey, kieApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys(); const generationsPath = useWorkflowStore((state) => state.generationsPath); const [externalModels, setExternalModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); @@ -67,15 +69,15 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps { @@ -84,16 +86,16 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps { fetchModels(); diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx index 7add034c..818156d7 100644 --- a/src/components/nodes/ModelParameters.tsx +++ b/src/components/nodes/ModelParameters.tsx @@ -3,7 +3,8 @@ import { useState, useEffect, useCallback } from "react"; import { ProviderType, ModelInputDef } from "@/types"; import { ModelParameter } from "@/lib/providers/types"; -import { useWorkflowStore } from "@/store/workflowStore"; +import { useProviderApiKeys } from "@/store/workflowStore"; +import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; interface ModelParametersProps { modelId: string; @@ -31,7 +32,8 @@ export function ModelParameters({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(true); - const providerSettings = useWorkflowStore((state) => state.providerSettings); + // Use stable selector for API keys to prevent unnecessary re-fetches + const { replicateApiKey, falApiKey } = useProviderApiKeys(); // Fetch schema when modelId changes useEffect(() => { @@ -47,15 +49,15 @@ export function ModelParameters({ try { const headers: HeadersInit = {}; - if (providerSettings.providers.replicate?.apiKey) { - headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey; + if (replicateApiKey) { + headers["X-Replicate-Key"] = replicateApiKey; } - if (providerSettings.providers.fal?.apiKey) { - headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey; + if (falApiKey) { + headers["X-Fal-Key"] = falApiKey; } const encodedModelId = encodeURIComponent(modelId); - const response = await fetch( + const response = await deduplicatedFetch( `/api/models/${encodedModelId}?provider=${provider}`, { headers } ); @@ -83,7 +85,7 @@ export function ModelParameters({ }; fetchSchema(); - }, [modelId, provider, providerSettings, onInputsLoaded]); + }, [modelId, provider, replicateApiKey, falApiKey, onInputsLoaded]); // Notify parent to resize node when schema loads and panel is expanded useEffect(() => { diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8c9c49db..f0418e8f 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { useShallow } from "zustand/shallow"; import { Connection, EdgeChange, @@ -3325,3 +3326,27 @@ export const useWorkflowStore = create((set, get) => ({ return { applied: result.applied, skipped: result.skipped }; }, })); + +/** + * Stable hook for provider API keys. + * + * Returns individual primitive values for each provider's API key. + * Uses shallow equality comparison to prevent re-renders when the + * providerSettings object reference changes but the actual key values + * don't change. + * + * This prevents unnecessary re-fetches of /api/models when multiple + * node instances subscribe to provider settings. + */ +export function useProviderApiKeys() { + return useWorkflowStore( + useShallow((state) => ({ + replicateApiKey: state.providerSettings.providers.replicate?.apiKey ?? null, + falApiKey: state.providerSettings.providers.fal?.apiKey ?? null, + kieApiKey: state.providerSettings.providers.kie?.apiKey ?? null, + // Provider enabled states (for conditional UI) + replicateEnabled: state.providerSettings.providers.replicate?.enabled ?? false, + kieEnabled: state.providerSettings.providers.kie?.enabled ?? false, + })) + ); +} diff --git a/src/utils/deduplicatedFetch.ts b/src/utils/deduplicatedFetch.ts new file mode 100644 index 00000000..aab8b641 --- /dev/null +++ b/src/utils/deduplicatedFetch.ts @@ -0,0 +1,123 @@ +/** + * Deduplicated fetch utility. + * + * When multiple callers request the same URL concurrently, this utility + * ensures only one actual network request is made. All callers receive + * the same response data once the request completes. + * + * This is useful for components that mount multiple instances (e.g., nodes) + * and all need to fetch the same models list - instead of N requests, + * only 1 request is made. + */ + +// Map of URL -> in-flight promise +const inFlightRequests = new Map>(); + +// Map of URL -> cloned response data (since Response body can only be read once) +const responseCache = new Map(); + +// Cache TTL in milliseconds (5 seconds - short enough to get fresh data, long enough to dedupe) +const CACHE_TTL = 5000; + +/** + * Generate a cache key from URL and headers + */ +function getCacheKey(url: string, headers?: HeadersInit): string { + if (!headers) return url; + + // Sort header keys for consistent key generation + const headerObj = headers instanceof Headers + ? Object.fromEntries(headers.entries()) + : Array.isArray(headers) + ? Object.fromEntries(headers) + : headers; + + const sortedHeaders = Object.keys(headerObj) + .sort() + .map((k) => `${k}:${headerObj[k]}`) + .join("|"); + + return `${url}|${sortedHeaders}`; +} + +/** + * Fetch with request deduplication. + * + * Multiple concurrent calls to the same URL (with same headers) will + * share a single network request. The response is cloned for each caller. + * + * @param url - The URL to fetch + * @param options - Fetch options (headers, etc.) + * @returns Promise resolving to the fetch Response + */ +export async function deduplicatedFetch( + url: string, + options?: RequestInit +): Promise { + const cacheKey = getCacheKey(url, options?.headers); + + // Check if we have a recent cached response + const cached = responseCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + // Return a synthetic response with the cached data + return new Response(JSON.stringify(cached.data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Check if there's already an in-flight request for this URL + const existingRequest = inFlightRequests.get(cacheKey); + if (existingRequest) { + // Wait for the existing request and clone it for this caller + const response = await existingRequest; + // The response body may have been consumed, so we rely on the cache + const cachedData = responseCache.get(cacheKey); + if (cachedData) { + return new Response(JSON.stringify(cachedData.data), { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } + // Fallback: this shouldn't happen, but return an error response + return new Response(JSON.stringify({ error: "Cache miss" }), { + status: 500, + }); + } + + // Create new request + const requestPromise = fetch(url, options) + .then(async (response) => { + // Clone and cache the response data before returning + if (response.ok) { + try { + const data = await response.clone().json(); + responseCache.set(cacheKey, { data, timestamp: Date.now() }); + } catch { + // Response wasn't JSON, that's fine + } + } + return response; + }) + .finally(() => { + // Clean up in-flight request after a short delay + // (allows concurrent calls that started just after to still benefit) + setTimeout(() => { + inFlightRequests.delete(cacheKey); + }, 50); + }); + + // Store the in-flight request + inFlightRequests.set(cacheKey, requestPromise); + + return requestPromise; +} + +/** + * Clear all cached responses. + * Useful for testing or when settings change. + */ +export function clearFetchCache(): void { + responseCache.clear(); + inFlightRequests.clear(); +}