From 60185fc68fe7de72d632342a8d9a354743127b33 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 15:30:56 -0700 Subject: [PATCH] improvement(navigation): eliminate hidden workspace requests # Conflicts: # apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx --- .../react-query-best-practices/SKILL.md | 2 + .../app/workspace/[workspaceId]/logs/logs.tsx | 37 +++-- .../providers/provider-models-loader.test.tsx | 132 ++++++++++++++++++ .../providers/provider-models-loader.tsx | 49 +++++-- .../components/deploy-modal/deploy-modal.tsx | 13 +- .../panel/components/deploy/deploy.tsx | 19 +++ apps/sim/hooks/queries/api-keys.ts | 34 ++++- .../navigation-request-gating.test.tsx | 120 ++++++++++++++++ apps/sim/hooks/queries/providers.ts | 15 +- .../sim/hooks/queries/workflow-mcp-servers.ts | 30 +++- apps/sim/hooks/queries/workspace.ts | 28 ++-- 11 files changed, 429 insertions(+), 50 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.test.tsx create mode 100644 apps/sim/hooks/queries/navigation-request-gating.test.tsx diff --git a/.agents/skills/react-query-best-practices/SKILL.md b/.agents/skills/react-query-best-practices/SKILL.md index 4074fd5dc54..3aba654493a 100644 --- a/.agents/skills/react-query-best-practices/SKILL.md +++ b/.agents/skills/react-query-best-practices/SKILL.md @@ -35,6 +35,8 @@ Read these before analyzing: - Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params +- Warm data for hover/focus intent with `queryClient.prefetchQuery` and shared `queryOptions`; never temporarily enable a mounted hidden observer, which can remain active after focus restoration and refetch data for closed UI +- When gating a query by view or modal state, move every consumer to the active query too: imperative refresh/pagination, loading and error feedback, and data-derived controls must never read a disabled query or placeholder data from a previous key ### Mutations - Use `onSettled` (not `onSuccess`) for cache reconciliation — it fires on both success and error diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 054a9e5ae03..d94f7de1b02 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -240,6 +240,7 @@ export default function Logs() { const viewMode = useFilterStore((s) => s.viewMode) const setViewMode = useFilterStore((s) => s.setViewMode) + const isDashboardView = viewMode === 'dashboard' const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { selectedLogId: null, @@ -277,7 +278,7 @@ export default function Logs() { const isSidebarOpenRef = useRef(false) const shouldScrollIntoViewRef = useRef(false) const resourceTableRef = useRef(null) - const logsRefetchRef = useRef<() => void>(() => {}) + const activeViewRefetchRef = useRef<() => void>(() => {}) const activeLogRefetchRef = useRef<() => void>(() => {}) const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) @@ -316,6 +317,7 @@ export default function Logs() { ) const selectedDetailQuery = useLogDetail(selectedLogId ?? undefined, workspaceId, { + enabled: isSidebarOpen, refetchInterval, }) @@ -352,6 +354,7 @@ export default function Logs() { ) const logsQuery = useLogsList(workspaceId, logFilters, { + enabled: !isDashboardView || isSidebarOpen, refetchInterval: isLive ? LIVE_REFRESH_INTERVAL_MS : false, }) @@ -370,6 +373,7 @@ export default function Logs() { ) const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, { + enabled: isDashboardView, refetchInterval: isLive ? LIVE_REFRESH_INTERVAL_MS : false, }) @@ -394,7 +398,14 @@ export default function Logs() { selectedLogIndexRef.current = selectedLogIndex selectedLogIdRef.current = selectedLogId isSidebarOpenRef.current = isSidebarOpen - logsRefetchRef.current = logsQuery.refetch + activeViewRefetchRef.current = () => { + if (isDashboardView) { + void dashboardStatsQuery.refetch() + } + if (!isDashboardView || isSidebarOpen) { + void logsQuery.refetch() + } + } activeLogRefetchRef.current = selectedDetailQuery.refetch logsQueryRef.current = { isFetching: logsQuery.isFetching, @@ -641,22 +652,25 @@ export default function Logs() { const handleRefresh = useCallback(() => { triggerVisualRefresh() - logsRefetchRef.current() - if (selectedLogIdRef.current) { + activeViewRefetchRef.current() + if (selectedLogIdRef.current && isSidebarOpenRef.current) { activeLogRefetchRef.current() } }, [triggerVisualRefresh]) - const prevIsFetchingRef = useRef(logsQuery.isFetching) + const activeViewIsFetching = isDashboardView + ? dashboardStatsQuery.isFetching || (isSidebarOpen && logsQuery.isFetching) + : logsQuery.isFetching + const prevIsFetchingRef = useRef(activeViewIsFetching) useEffect(() => { const wasFetching = prevIsFetchingRef.current - const isFetching = logsQuery.isFetching + const isFetching = activeViewIsFetching prevIsFetchingRef.current = isFetching if (isLive && !wasFetching && isFetching) { triggerVisualRefresh() } - }, [logsQuery.isFetching, isLive, triggerVisualRefresh]) + }, [activeViewIsFetching, isLive, triggerVisualRefresh]) const handleExport = useCallback(async () => { setIsExporting(true) @@ -777,8 +791,6 @@ export default function Logs() { setPreviewLogId(null) } - const isDashboardView = viewMode === 'dashboard' - const rows: ResourceRow[] = useMemo( () => logs.map((log) => { @@ -1135,6 +1147,9 @@ export default function Logs() { ) const refreshIcon = isVisuallyRefreshing ? SpinningRefreshCw : RefreshCw + const hasExportableLogs = isDashboardView + ? !dashboardStatsQuery.isPlaceholderData && (dashboardStatsQuery.data?.totalRuns ?? 0) > 0 + : !logsQuery.isPlaceholderData && logs.length > 0 const headerActions = useMemo( () => [ @@ -1142,7 +1157,7 @@ export default function Logs() { text: 'Export', icon: Download, onSelect: handleExport, - disabled: !userPermissions.canEdit || isExporting || logs.length === 0, + disabled: !userPermissions.canEdit || isExporting || !hasExportableLogs, }, { text: 'Refresh', @@ -1170,7 +1185,7 @@ export default function Logs() { handleExport, userPermissions.canEdit, isExporting, - logs.length, + hasExportableLogs, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.test.tsx b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.test.tsx new file mode 100644 index 00000000000..187d532f3bf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.test.tsx @@ -0,0 +1,132 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + pathname: '/workspace/workspace-1/tables', + workspaceId: 'workspace-1' as string | undefined, + searchOpen: false, + useProviderModels: vi.fn(() => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + })), + setProviderModels: vi.fn(), + setProviderLoading: vi.fn(), + setOpenRouterModelInfo: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn() }), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: mocks.workspaceId }), + usePathname: () => mocks.pathname, +})) + +vi.mock('@/hooks/queries/providers', () => ({ + useProviderModels: mocks.useProviderModels, +})) + +vi.mock('@/providers/utils', () => ({ + updateBasetenProviderModels: vi.fn(), + updateFireworksProviderModels: vi.fn(), + updateLiteLLMProviderModels: vi.fn(), + updateOllamaCloudProviderModels: vi.fn(), + updateOllamaProviderModels: vi.fn(), + updateOpenRouterProviderModels: vi.fn(), + updateTogetherProviderModels: vi.fn(), + updateVLLMProviderModels: vi.fn(), +})) + +vi.mock('@/stores/modals/search/store', () => ({ + useSearchModalStore: (selector: (state: { isOpen: boolean }) => unknown) => + selector({ isOpen: mocks.searchOpen }), +})) + +vi.mock('@/stores/providers', () => ({ + useProvidersStore: ( + selector: (state: { + setProviderModels: typeof mocks.setProviderModels + setProviderLoading: typeof mocks.setProviderLoading + setOpenRouterModelInfo: typeof mocks.setOpenRouterModelInfo + }) => unknown + ) => + selector({ + setProviderModels: mocks.setProviderModels, + setProviderLoading: mocks.setProviderLoading, + setOpenRouterModelInfo: mocks.setOpenRouterModelInfo, + }), +})) + +import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' + +let root: Root + +function renderLoader() { + act(() => { + root.render() + }) +} + +function expectEveryProviderEnabled(enabled: boolean) { + expect(mocks.useProviderModels).toHaveBeenCalledTimes(9) + for (const call of mocks.useProviderModels.mock.calls) { + expect(call[2]).toEqual({ enabled }) + } +} + +describe('ProviderModelsLoader request gating', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + root = createRoot(document.createElement('div')) + mocks.pathname = '/workspace/workspace-1/tables' + mocks.workspaceId = 'workspace-1' + mocks.searchOpen = false + }) + + afterEach(() => { + act(() => root.unmount()) + vi.clearAllMocks() + }) + + it.each(['tables', 'knowledge', 'files', 'logs', 'settings'])( + 'defers every provider catalog on the %s route', + (route) => { + mocks.pathname = `/workspace/workspace-1/${route}` + renderLoader() + + expectEveryProviderEnabled(false) + } + ) + + it.each(['home', 'w/workflow-1', 'chat/chat-1'])( + 'loads every provider catalog on the %s route', + (route) => { + mocks.pathname = `/workspace/workspace-1/${route}` + renderLoader() + + expectEveryProviderEnabled(true) + } + ) + + it('loads every provider catalog when global search opens on a resource route', () => { + mocks.searchOpen = true + renderLoader() + + expectEveryProviderEnabled(true) + }) + + it('does not create an empty-workspace route prefix', () => { + mocks.workspaceId = undefined + mocks.searchOpen = true + renderLoader() + + expectEveryProviderEnabled(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx index 0622fafa858..363ca849ae5 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { createLogger } from '@sim/logger' -import { useParams } from 'next/navigation' +import { useParams, usePathname } from 'next/navigation' import { useProviderModels } from '@/hooks/queries/providers' import { updateBasetenProviderModels, @@ -14,15 +14,37 @@ import { updateTogetherProviderModels, updateVLLMProviderModels, } from '@/providers/utils' +import { useSearchModalStore } from '@/stores/modals/search/store' import { type ProviderName, useProvidersStore } from '@/stores/providers' const logger = createLogger('ProviderModelsLoader') -function useSyncProvider(provider: ProviderName, workspaceId?: string) { +function shouldLoadProviderModels( + pathname: string | null, + workspaceId: string | undefined, + isSearchModalOpen: boolean +): boolean { + if (!workspaceId) return false + if (isSearchModalOpen) return true + + const workspaceBase = `/workspace/${workspaceId}` + return ( + pathname === workspaceBase || + pathname === `${workspaceBase}/home` || + pathname === `${workspaceBase}/w` || + pathname?.startsWith(`${workspaceBase}/w/`) === true || + pathname === `${workspaceBase}/chat` || + pathname?.startsWith(`${workspaceBase}/chat/`) === true + ) +} + +function useSyncProvider(provider: ProviderName, enabled: boolean, workspaceId?: string) { const setProviderModels = useProvidersStore((state) => state.setProviderModels) const setProviderLoading = useProvidersStore((state) => state.setProviderLoading) const setOpenRouterModelInfo = useProvidersStore((state) => state.setOpenRouterModelInfo) - const { data, isLoading, isFetching, error } = useProviderModels(provider, workspaceId) + const { data, isLoading, isFetching, error } = useProviderModels(provider, workspaceId, { + enabled, + }) useEffect(() => { setProviderLoading(provider, isLoading || isFetching) @@ -68,16 +90,19 @@ function useSyncProvider(provider: ProviderName, workspaceId?: string) { export function ProviderModelsLoader() { const params = useParams() + const pathname = usePathname() const workspaceId = params?.workspaceId as string | undefined + const isSearchModalOpen = useSearchModalStore((state) => state.isOpen) + const shouldLoad = shouldLoadProviderModels(pathname, workspaceId, isSearchModalOpen) - useSyncProvider('base') - useSyncProvider('ollama') - useSyncProvider('ollama-cloud', workspaceId) - useSyncProvider('vllm') - useSyncProvider('litellm') - useSyncProvider('openrouter') - useSyncProvider('fireworks', workspaceId) - useSyncProvider('together', workspaceId) - useSyncProvider('baseten', workspaceId) + useSyncProvider('base', shouldLoad) + useSyncProvider('ollama', shouldLoad) + useSyncProvider('ollama-cloud', shouldLoad, workspaceId) + useSyncProvider('vllm', shouldLoad) + useSyncProvider('litellm', shouldLoad) + useSyncProvider('openrouter', shouldLoad) + useSyncProvider('fireworks', shouldLoad, workspaceId) + useSyncProvider('together', shouldLoad, workspaceId) + useSyncProvider('baseten', shouldLoad, workspaceId) return null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 0b38610706e..81e0a5ed9cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -140,9 +140,14 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() - const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') + const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys( + workflowWorkspaceId || '', + 'combined', + { enabled: open } + ) const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( - workflowWorkspaceId || '' + workflowWorkspaceId || '', + { enabled: open } ) const apiKeyWorkspaceKeys = apiKeysData?.workspaceKeys || [] const apiKeyPersonalKeys = apiKeysData?.personalKeys || [] @@ -170,7 +175,9 @@ export function DeployModal({ refetch: refetchChatInfo, } = useChatDeploymentInfo(workflowId, { enabled: open }) - const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '') + const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '', { + enabled: open, + }) const hasMcpServers = mcpServers.length > 0 const deployMutation = useDeployWorkflow() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 866d244ef0d..d577ede1b32 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -2,6 +2,8 @@ import { useState } from 'react' import { Chip, Tooltip, toast } from '@sim/emcn' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { @@ -10,6 +12,9 @@ import { useDeployReadiness, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' +import { apiKeysQueryOptions } from '@/hooks/queries/api-keys' +import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers' +import { workspaceSettingsQueryOptions } from '@/hooks/queries/workspace' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -20,6 +25,9 @@ interface DeployProps { } export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: DeployProps) { + const queryClient = useQueryClient() + const params = useParams() + const workspaceId = params.workspaceId as string | undefined const [isModalOpen, setIsModalOpen] = useState(false) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' @@ -151,6 +159,15 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: } } + const prefetchDeployModal = () => { + if (!workspaceId || isRegistryLoading || isDisabled) return + void Promise.all([ + queryClient.prefetchQuery(apiKeysQueryOptions(workspaceId, 'combined')), + queryClient.prefetchQuery(workspaceSettingsQueryOptions(workspaceId)), + queryClient.prefetchQuery(workflowMcpServersQueryOptions(workspaceId)), + ]) + } + return ( <> @@ -159,6 +176,8 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: {getButtonLabel()} diff --git a/apps/sim/hooks/queries/api-keys.ts b/apps/sim/hooks/queries/api-keys.ts index b8984dee514..6ea84cea7c8 100644 --- a/apps/sim/hooks/queries/api-keys.ts +++ b/apps/sim/hooks/queries/api-keys.ts @@ -1,4 +1,10 @@ -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { @@ -38,6 +44,10 @@ type CombinedApiKeysData = { export type ApiKeyScope = 'combined' | 'personal' | 'workspace' +interface UseApiKeysOptions { + enabled?: boolean +} + /** * Fetch API keys for one settings plane, or both for compatibility callers. */ @@ -77,11 +87,8 @@ export async function fetchApiKeys( } } -/** - * Hook to fetch API keys for the requested settings plane. - */ -export function useApiKeys(workspaceId: string, scope: ApiKeyScope = 'combined') { - return useQuery({ +export function apiKeysQueryOptions(workspaceId: string, scope: ApiKeyScope = 'combined') { + return queryOptions({ queryKey: scope === 'personal' ? apiKeysKeys.personal() @@ -89,12 +96,25 @@ export function useApiKeys(workspaceId: string, scope: ApiKeyScope = 'combined') ? apiKeysKeys.workspace(workspaceId) : apiKeysKeys.combined(workspaceId), queryFn: ({ signal }) => fetchApiKeys(workspaceId, scope, signal), - enabled: scope === 'personal' || !!workspaceId, staleTime: API_KEYS_COMBINED_STALE_TIME, placeholderData: scope === 'personal' ? undefined : keepPreviousData, }) } +/** + * Hook to fetch API keys for the requested settings plane. + */ +export function useApiKeys( + workspaceId: string, + scope: ApiKeyScope = 'combined', + options?: UseApiKeysOptions +) { + return useQuery({ + ...apiKeysQueryOptions(workspaceId, scope), + enabled: (scope === 'personal' || !!workspaceId) && (options?.enabled ?? true), + }) +} + /** * Create API key mutation params */ diff --git a/apps/sim/hooks/queries/navigation-request-gating.test.tsx b/apps/sim/hooks/queries/navigation-request-gating.test.tsx new file mode 100644 index 00000000000..be703cc218e --- /dev/null +++ b/apps/sim/hooks/queries/navigation-request-gating.test.tsx @@ -0,0 +1,120 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +import { + getWorkspaceContract, + getWorkspacePermissionsContract, + listPersonalApiKeysContract, + listWorkspaceApiKeysContract, +} from '@/lib/api/contracts' +import { listWorkflowMcpServersContract } from '@/lib/api/contracts/workflow-mcp-servers' +import { apiKeysQueryOptions, useApiKeys } from '@/hooks/queries/api-keys' +import { + useWorkflowMcpServers, + workflowMcpServersQueryOptions, +} from '@/hooks/queries/workflow-mcp-servers' +import { useWorkspaceSettings, workspaceSettingsQueryOptions } from '@/hooks/queries/workspace' + +let root: Root +let queryClient: QueryClient + +function QueryProbe({ enabled }: { enabled: boolean }) { + useApiKeys('workspace-1', 'combined', { enabled }) + useWorkspaceSettings('workspace-1', { enabled }) + useWorkflowMcpServers('workspace-1', { enabled }) + return null +} + +function renderProbe(enabled: boolean) { + act(() => { + root.render( + + + + ) + }) +} + +async function flushQueries() { + await act(async () => { + for (let index = 0; index < 5; index++) { + await Promise.resolve() + await sleep(1) + } + }) +} + +describe('navigation request gating', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + root = createRoot(document.createElement('div')) + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mockRequestJson.mockResolvedValue({ keys: [], data: { servers: [] } }) + }) + + afterEach(() => { + act(() => root.unmount()) + queryClient.clear() + vi.clearAllMocks() + }) + + it('makes no deploy-support requests while the modal has no user intent', async () => { + renderProbe(false) + await flushQueries() + + expect(mockRequestJson).not.toHaveBeenCalled() + }) + + it('loads each deploy dependency exactly once after user intent', async () => { + renderProbe(false) + renderProbe(true) + await flushQueries() + + expect(mockRequestJson).toHaveBeenCalledTimes(5) + expect(mockRequestJson.mock.calls.map(([contract]) => contract)).toEqual( + expect.arrayContaining([ + listWorkspaceApiKeysContract, + listPersonalApiKeysContract, + getWorkspaceContract, + getWorkspacePermissionsContract, + listWorkflowMcpServersContract, + ]) + ) + }) + + it('warms deploy dependencies without leaving hidden query observers active', async () => { + await Promise.all([ + queryClient.prefetchQuery(apiKeysQueryOptions('workspace-1', 'combined')), + queryClient.prefetchQuery(workspaceSettingsQueryOptions('workspace-1')), + queryClient.prefetchQuery(workflowMcpServersQueryOptions('workspace-1')), + ]) + + expect(mockRequestJson).toHaveBeenCalledTimes(5) + expect( + queryClient + .getQueryCache() + .getAll() + .every((query) => query.getObserversCount() === 0) + ).toBe(true) + + renderProbe(false) + renderProbe(true) + await flushQueries() + + expect(mockRequestJson).toHaveBeenCalledTimes(5) + }) +}) diff --git a/apps/sim/hooks/queries/providers.ts b/apps/sim/hooks/queries/providers.ts index f7f8afa8745..6f430b6a619 100644 --- a/apps/sim/hooks/queries/providers.ts +++ b/apps/sim/hooks/queries/providers.ts @@ -19,6 +19,10 @@ import type { ProviderName } from '@/stores/providers' type ProviderModelSource = ProviderName | 'openrouter-embeddings' +interface UseProviderModelsOptions { + enabled?: boolean +} + const logger = createLogger('ProviderModelsQuery') export const PROVIDER_MODELS_STALE_TIME = 5 * 60 * 1000 @@ -101,6 +105,13 @@ export function providerModelsQueryOptions(provider: ProviderModelSource, worksp }) } -export function useProviderModels(provider: ProviderModelSource, workspaceId?: string) { - return useQuery(providerModelsQueryOptions(provider, workspaceId)) +export function useProviderModels( + provider: ProviderModelSource, + workspaceId?: string, + options?: UseProviderModelsOptions +) { + return useQuery({ + ...providerModelsQueryOptions(provider, workspaceId), + enabled: options?.enabled ?? true, + }) } diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.ts b/apps/sim/hooks/queries/workflow-mcp-servers.ts index 3e9e64d412e..849169d2398 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.ts +++ b/apps/sim/hooks/queries/workflow-mcp-servers.ts @@ -1,5 +1,11 @@ import { createLogger } from '@sim/logger' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { @@ -43,6 +49,10 @@ export const WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME = 30 * 1000 export const WORKFLOW_MCP_TOOLS_STALE_TIME = 30 * 1000 export const WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME = 30 * 1000 +interface UseWorkflowMcpServersOptions { + enabled?: boolean +} + /** * Fetch workflow MCP servers for a workspace */ @@ -64,20 +74,26 @@ async function fetchWorkflowMcpServers( } } -/** - * Hook to fetch workflow MCP servers - */ -export function useWorkflowMcpServers(workspaceId: string) { - return useQuery({ +export function workflowMcpServersQueryOptions(workspaceId: string) { + return queryOptions({ queryKey: workflowMcpServerKeys.servers(workspaceId), queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal), - enabled: !!workspaceId, retry: false, staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } +/** + * Hook to fetch workflow MCP servers + */ +export function useWorkflowMcpServers(workspaceId: string, options?: UseWorkflowMcpServersOptions) { + return useQuery({ + ...workflowMcpServersQueryOptions(workspaceId), + enabled: !!workspaceId && (options?.enabled ?? true), + }) +} + /** * Fetch a single workflow MCP server with its tools */ diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index f98748e1ea6..55d1e54ec38 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -1,5 +1,11 @@ import type { QueryClient } from '@tanstack/react-query' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' @@ -378,24 +384,30 @@ async function fetchWorkspaceSettings(workspaceId: string, signal?: AbortSignal) */ export function prefetchWorkspaceSettings(queryClient: QueryClient, workspaceId: string) { if (!workspaceId) return - queryClient.prefetchQuery({ + queryClient.prefetchQuery(workspaceSettingsQueryOptions(workspaceId)) +} + +export function workspaceSettingsQueryOptions(workspaceId: string) { + return queryOptions({ queryKey: workspaceKeys.settings(workspaceId), queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal), staleTime: WORKSPACE_SETTINGS_STALE_TIME, + placeholderData: keepPreviousData, }) } +interface UseWorkspaceSettingsOptions { + enabled?: boolean +} + /** * Fetches workspace settings including permissions. * @param workspaceId - The workspace ID to fetch settings for */ -export function useWorkspaceSettings(workspaceId: string) { +export function useWorkspaceSettings(workspaceId: string, options?: UseWorkspaceSettingsOptions) { return useQuery({ - queryKey: workspaceKeys.settings(workspaceId), - queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal), - enabled: !!workspaceId, - staleTime: WORKSPACE_SETTINGS_STALE_TIME, - placeholderData: keepPreviousData, + ...workspaceSettingsQueryOptions(workspaceId), + enabled: !!workspaceId && (options?.enabled ?? true), }) }