Browse Source

fix: deduplicate API calls to /api/models across node instances

Multiple node instances (GenerateImageNode, GenerateVideoNode, etc.) were
each making their own /api/models requests when mounted, causing N duplicate
network requests when N nodes existed on the canvas.

This fix:
- Adds deduplicatedFetch utility that shares in-flight requests to the same URL
- Adds useProviderApiKeys() hook with shallow equality for stable API key refs
- Updates all components to use primitive dependencies instead of object refs
- Uses deduplicatedFetch for all model-fetching operations

Expected result: 1 request instead of N when multiple nodes need the same data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
lapoaiscalers 5 months ago
parent
commit
0df8677397
  1. 47
      src/components/modals/ModelSearchDialog.tsx
  2. 33
      src/components/nodes/GenerateImageNode.tsx
  3. 28
      src/components/nodes/GenerateVideoNode.tsx
  4. 18
      src/components/nodes/ModelParameters.tsx
  5. 25
      src/store/workflowStore.ts
  6. 123
      src/utils/deduplicatedFetch.ts

47
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<HTMLInputElement>(null);
const abortControllerRef = useRef<AbortController | null>(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<string, string> = {};
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(() => {

33
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<NanoBananaNo
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => 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<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
@ -81,24 +83,23 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
// fal.ai is always available (works without key but rate limited)
providers.push({ id: "fal", name: "fal.ai" });
// Add Replicate if configured
if (providerSettings.providers.replicate?.enabled && providerSettings.providers.replicate?.apiKey) {
if (replicateEnabled && replicateApiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
// Add Kie.ai if configured
if (providerSettings.providers.kie?.enabled && providerSettings.providers.kie?.apiKey) {
if (kieEnabled && kieApiKey) {
providers.push({ id: "kie", name: "Kie.ai" });
}
return providers;
}, [providerSettings]);
}, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey]);
// Check if external providers (Replicate/Fal) are enabled
// fal.ai is always available (works without key but rate limited)
const hasExternalProviders = useMemo(() => {
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<NanoBananaNo
try {
const capabilities = IMAGE_CAPABILITIES.join(",");
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;
}
if (providerSettings.providers.kie?.apiKey) {
headers["X-Kie-Key"] = providerSettings.providers.kie.apiKey;
if (kieApiKey) {
headers["X-Kie-Key"] = kieApiKey;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
const response = await deduplicatedFetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
@ -159,7 +160,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
} finally {
setIsLoadingModels(false);
}
}, [currentProvider, providerSettings]);
}, [currentProvider, replicateApiKey, falApiKey, kieApiKey]);
useEffect(() => {
fetchModels();

28
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<GenerateVide
const nodeData = data;
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => 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<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
@ -67,15 +69,15 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// fal.ai is always available (works without key but rate limited)
providers.push({ id: "fal", name: "fal.ai" });
// Add Replicate if configured
if (providerSettings.providers.replicate?.enabled && providerSettings.providers.replicate?.apiKey) {
if (replicateEnabled && replicateApiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
// Add Kie.ai if configured
if (providerSettings.providers.kie?.enabled && providerSettings.providers.kie?.apiKey) {
if (kieEnabled && kieApiKey) {
providers.push({ id: "kie", name: "Kie.ai" });
}
return providers;
}, [providerSettings]);
}, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey]);
// Fetch models from external providers when provider changes
const fetchModels = useCallback(async () => {
@ -84,16 +86,16 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
try {
const capabilities = VIDEO_CAPABILITIES.join(",");
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;
}
if (providerSettings.providers.kie?.apiKey) {
headers["X-Kie-Key"] = providerSettings.providers.kie.apiKey;
if (kieApiKey) {
headers["X-Kie-Key"] = kieApiKey;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
const response = await deduplicatedFetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
@ -115,7 +117,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
} finally {
setIsLoadingModels(false);
}
}, [currentProvider, providerSettings]);
}, [currentProvider, replicateApiKey, falApiKey, kieApiKey]);
useEffect(() => {
fetchModels();

18
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<string | null>(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(() => {

25
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<WorkflowStore>((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,
}))
);
}

123
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<string, Promise<Response>>();
// Map of URL -> cloned response data (since Response body can only be read once)
const responseCache = new Map<string, { data: unknown; timestamp: number }>();
// 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<Response> {
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();
}
Loading…
Cancel
Save