Browse Source
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
6 changed files with 216 additions and 58 deletions
@ -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…
Reference in new issue