diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx index 2c56d1d9837..999260b02e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx @@ -1,8 +1,13 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { notFound } from 'next/navigation' import { getSession } from '@/lib/auth' +import { isChatEnabled } from '@/lib/core/config/env-flags' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { Home } from '@/app/workspace/[workspaceId]/home/home' import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' +import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch' import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag' export const metadata: Metadata = { @@ -17,18 +22,30 @@ interface ChatPageProps { } export default async function ChatPage({ params }: ChatPageProps) { + // The layout 404s too, but pages and layouts resolve concurrently — without this + // the prefetch below still fires on its way out. + if (!isChatEnabled) { + notFound() + } + const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()]) const userId = session?.user?.id - const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId) + const queryClient = getQueryClient() + const [tableViewsEnabled] = await Promise.all([ + resolveTableViewsEnabled(workspaceId, userId), + prefetchHomeSurface(queryClient, workspaceId, userId), + ]) return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index 54d0276fcef..dd08f5fc925 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -3,6 +3,7 @@ import { listWorkspaceFileFoldersContract } from '@/lib/api/contracts/workspace- import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' +import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files' import { WORKSPACE_FILE_FOLDERS_STALE_TIME, workspaceFileFolderKeys, @@ -15,14 +16,8 @@ import { * the Owner column — under the same query keys their client hooks (`useWorkspaceFileFolders`) use * (scope `active`), so the browser paints populated on first render. * - * The FILE LIST itself is deliberately not here: the sidebar reads it on every workspace route, so - * it is seeded by `prefetchWorkspaceSidebar` in the layout — the only boundary that renders - * before the sidebar registers the query. Prefetching it again here would re-read it per request - * and still not reach the server render (`HydrationBoundary` defers an already-seen query to an - * effect, which SSR never runs). See the note on that entry. The layout declines to seed a - * workspace whose file list exceeds its payload budget; recovering those here would mean - * mirroring that budget check inversely, since an unconditional prefetch would re-read and - * duplicate the entry for every workspace under the budget. + * The file list is seeded here rather than in the layout so only the routes that render it pay for + * it. See {@link seedWorkspaceFiles} for why a large workspace seeds nothing at all. * * Folders and the chrome reads all go through the data layer, shaped to their route contracts so a * hydrated entry matches a client fetch. @@ -58,5 +53,6 @@ export async function prefetchFilesBrowser( staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, }), prefetchResourceListChrome(queryClient, workspaceId, 'file', userId), + seedWorkspaceFiles(queryClient, workspaceId), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index cfb87f8ce04..3b54ff2d55d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,8 +1,11 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { isChatEnabled } from '@/lib/core/config/env-flags' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch' import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag' import { Home } from './home' import { HomeFallback } from './home-fallback' @@ -20,19 +23,23 @@ export default async function HomePage({ params }: { params: Promise<{ workspace redirect(`/workspace/${workspaceId}`) } - /** - * Home prefetches nothing of its own. Both lists it reads — workflow folders and - * the workspace file list — are hydrated by `prefetchWorkspaceSidebar` in the - * layout under the same keys, and re-reading them here would cost a second query - * per request without reaching the server render. - */ const session = await getSession() const userId = session?.user?.id - const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId) + const queryClient = getQueryClient() + const [tableViewsEnabled] = await Promise.all([ + resolveTableViewsEnabled(workspaceId, userId), + prefetchHomeSurface(queryClient, workspaceId, userId), + ]) return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts new file mode 100644 index 00000000000..b47b000b0f1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -0,0 +1,27 @@ +import type { QueryClient } from '@tanstack/react-query' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files' + +/** + * Prefetches what the Home surface needs on top of the workspace layout's own prefetch. + * + * Home reads the workspace file list on mount (resource tabs, mentions, the resource picker), so + * the list is seeded by the routes that render Home rather than by the layout: seeding it in the + * layout would pay for it on every workspace route, including the ones that never read it. + * + * The seed carries no authorization of its own, so the viewer is proved first. This reuses the + * layout's `cache`d host-context lookup rather than re-deriving the permission, so it costs no + * additional queries; a viewer without access caches nothing and the client fetch reaches the + * route for the real 403. + */ +export async function prefetchHomeSurface( + queryClient: QueryClient, + workspaceId: string, + userId: string | undefined +): Promise { + if (!userId) return + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) + if (!hostContext) return + + await seedWorkspaceFiles(queryClient, workspaceId) +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index c27a77959b0..6bb77574bb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -97,10 +97,7 @@ vi.mock('@sim/emcn', () => ({ import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' -import { - prefetchWorkspaceSidebar, - WORKSPACE_FILE_SEED_MAX, -} from '@/app/workspace/[workspaceId]/prefetch' +import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch' import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -357,19 +354,18 @@ describe('workspace list prefetches', () => { }) /** - * The FILE LIST is deliberately not primed here — `prefetchWorkspaceSidebar` owns it, because the - * sidebar reads that query on every workspace route and therefore registers it before any page - * renders. `HydrationBoundary` hands an already-seen query to a `useEffect`, which SSR never runs, - * so a page-level prefetch of this key costs a request per render and still cannot reach the server - * render. Restoring it here would reintroduce exactly that. + * The file list is the browser's primary content, so it must be seeded by the page that + * renders it — the layout no longer seeds it, which would have charged every workspace + * route for a list only a few of them read. */ - it('leaves the file list to the layout rather than re-reading it per page', async () => { + it('seeds the file list the browser renders', async () => { + const files = [{ id: 'file-1' }] + mockListWorkspaceFilesWithShares.mockResolvedValue(files) const client = makeClient() await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) - expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) }) /** @@ -514,48 +510,15 @@ describe('workspace list prefetches', () => { }) /** - * The file list is seeded on every workspace route, so it is the one entry whose size - * scales with a workspace's content on routes that never read it. The budget is passed - * down rather than applied here, so the read can stop before the share join. - */ - it('seeds the file list, bounded by the document payload budget', async () => { - const files = [{ id: 'file-1', name: 'a.txt' }] - mockListWorkspaceFilesWithShares.mockResolvedValue(files) - const client = makeClient() - - await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) - - expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', { - maxRows: WORKSPACE_FILE_SEED_MAX, - /** A failed read must reach the catch, not degrade to a cached empty list. */ - throwOnError: true, - }) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) - }) - - /** - * The load-bearing half of the budget: a workspace over it seeds NOTHING rather than the - * prefix that was read. The sidebar search filters this list client-side and the Files - * browser renders it as the workspace's files, so a truncated seed would silently hide - * files — the client fetch must reach the route for the complete list instead. + * The file list belongs to the pages that render it, not to every workspace route. A sidebar + * seed would charge the workflow editor, logs, and settings for a read none of them make. */ - it('seeds nothing when the workspace exceeds the budget', async () => { - mockListWorkspaceFilesWithShares.mockResolvedValue(null) + it('does not read the workspace file list', async () => { const client = makeClient() await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() - }) - - /** A failed file read is an optimization loss, not a render failure. */ - it('does not throw when the file read rejects, and seeds no files', async () => { - mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500')) - const client = makeClient() - - await expect( - prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) - ).resolves.toBeUndefined() + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() }) @@ -591,9 +554,9 @@ describe('workspace list prefetches', () => { ], [ /** - * Asserted against the folder key, not the file list: `prefetchFilesBrowser` - * deliberately never seeds `workspaceFilesKeys` (the layout owns it), so an - * assertion on that key would hold no matter what this function did. + * Asserted against the folder key: the file list is seeded rather than prefetched, so + * a rejecting read leaves that key empty by design and could not distinguish a + * swallowed failure from a function that did nothing. */ 'prefetchFilesBrowser', (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), diff --git a/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.test.ts new file mode 100644 index 00000000000..458113e2a1d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListWorkspaceFilesWithShares } = vi.hoisted(() => ({ + mockListWorkspaceFilesWithShares: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/queries', () => ({ + listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares, +})) + +/** The key factory lives in a `'use client'` module that pulls emcn's CSS at import. */ +vi.mock('@sim/emcn', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +import { + seedWorkspaceFiles, + WORKSPACE_FILE_SEED_MAX, +} from '@/app/workspace/[workspaceId]/lib/seed-workspace-files' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +const WORKSPACE_ID = 'ws-123' + +function makeClient() { + const store = new Map() + return { + setQueryData: (key: readonly unknown[], value: unknown) => + store.set(JSON.stringify(key), value), + getQueryData: (key: readonly unknown[]) => store.get(JSON.stringify(key)), + } as never as import('@tanstack/react-query').QueryClient & { + getQueryData: (key: readonly unknown[]) => unknown + } +} + +describe('seedWorkspaceFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('seeds the file list, bounded by the document payload budget', async () => { + const files = [{ id: 'file-1', name: 'a.txt' }] + mockListWorkspaceFilesWithShares.mockResolvedValue(files) + const client = makeClient() + + await seedWorkspaceFiles(client, WORKSPACE_ID) + + expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', { + maxRows: WORKSPACE_FILE_SEED_MAX, + /** A failed read must reach the catch, not degrade to a cached empty list. */ + throwOnError: true, + }) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) + }) + + /** + * A workspace over the budget seeds NOTHING rather than the prefix that was read: the + * Files browser renders this list as the workspace's files, so a truncated seed would + * silently hide some. The client fetch reaches the route for the complete list instead. + */ + it('seeds nothing when the workspace exceeds the budget', async () => { + mockListWorkspaceFilesWithShares.mockResolvedValue(null) + const client = makeClient() + + await seedWorkspaceFiles(client, WORKSPACE_ID) + + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() + }) + + /** A failed read is an optimization loss, not a render failure. */ + it('does not throw when the read rejects, and seeds nothing', async () => { + mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500')) + const client = makeClient() + + await expect(seedWorkspaceFiles(client, WORKSPACE_ID)).resolves.toBeUndefined() + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts new file mode 100644 index 00000000000..d2a476f3a78 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { QueryClient } from '@tanstack/react-query' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +const logger = createLogger('SeedWorkspaceFiles') + +/** + * How many files a page is willing to inline into its document. At ~500 bytes of JSON per + * file this budgets the entry at ~150 KB. + * + * A workspace above the budget seeds NOTHING rather than a prefix: the Files browser + * renders this list as the workspace's files, so a truncated seed would silently hide some. + */ +export const WORKSPACE_FILE_SEED_MAX = 300 + +/** + * Seeds the workspace's file list for the pages that render it. + * + * Seeded rather than prefetched so it can decline to create an entry at all above + * {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial + * entry would be read as the whole list. Parsed through the route's response contract, so + * a seeded entry matches what a client fetch caches. + */ +export async function seedWorkspaceFiles( + queryClient: QueryClient, + workspaceId: string +): Promise { + try { + const files = await listWorkspaceFilesWithShares(workspaceId, 'active', { + maxRows: WORKSPACE_FILE_SEED_MAX, + /** + * A failed read must reach the catch below, not degrade to an empty list: seeding + * `[]` would cache "this workspace has no files" as authoritative for the entry's + * lifetime, which is worse than seeding nothing and letting the client fetch. + */ + throwOnError: true, + }) + if (!files) return + queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files) + } catch (error) { + /** Optimization only: the client fetch reaches the route instead. */ + logger.warn('Workspace file list seed failed; client will fetch', { + error: getErrorMessage(error), + }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index e19e970b581..ebabb4df975 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -6,7 +6,6 @@ import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { getUserProfile } from '@/lib/users/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' -import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' @@ -25,7 +24,6 @@ import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' -import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' import { WORKSPACE_HOST_CONTEXT_STALE_TIME, workspaceHostKeys, @@ -92,46 +90,6 @@ async function seedWorkspaceList( } } -/** - * How many files the layout is willing to inline into the document. Seeded on EVERY - * workspace route, so at ~500 bytes of JSON per file this budgets the entry at ~150 KB. - * - * A workspace above the budget seeds NOTHING rather than a prefix: the sidebar filters - * this list client-side, so a truncated seed would silently hide files. - */ -export const WORKSPACE_FILE_SEED_MAX = 300 - -/** - * Seeds the workspace's file list, which sidebar chrome registers on EVERY workspace - * route. It must be seeded HERE, not by the Files pages: `HydrationBoundary` defers a - * query the cache has already seen to a `useEffect`, which SSR never runs. - * - * Seeded rather than prefetched so it can decline to create an entry at all above - * {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial - * entry would be read as the whole list. Parsed through the route's response contract. - */ -async function seedWorkspaceFiles(queryClient: QueryClient, workspaceId: string): Promise { - try { - const files = await listWorkspaceFilesWithShares(workspaceId, 'active', { - maxRows: WORKSPACE_FILE_SEED_MAX, - /** - * A failed read must reach the catch below, not degrade to an empty list: seeding - * `[]` would cache "this workspace has no files" as authoritative for the entry's - * lifetime, which is worse than seeding nothing and letting the client fetch. - */ - throwOnError: true, - }) - if (!files) return - queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files) - } catch (error) { - /** Optimization only: the client fetch reaches the route instead. Logged so drift between - * this read and the contract's response schema doesn't degrade silently into a waterfall. */ - logger.warn('Workspace file list seed failed; client will fetch', { - error: getErrorMessage(error), - }) - } -} - /** * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, * workspace, and viewer-profile reads for a workspace and stores them under the @@ -190,7 +148,6 @@ export async function prefetchWorkspaceSidebar( ] : []), prefetchResourceFolders(queryClient, workspaceId, 'workflow', userId), - seedWorkspaceFiles(queryClient, workspaceId), queryClient.prefetchQuery({ queryKey: workspaceKeys.permissions(workspaceId), queryFn: () => diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 625a3de8378..e8a681fc477 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -72,6 +72,25 @@ vi.mock('@/hooks/use-permission-config', () => ({ }), })) +/** + * The palette owns these reads now — it mounts only while open, so the queries exist only then. + * `mockTables` lets a test drive the Tables section the way the `tables` prop used to. + */ +const mockTables = vi.hoisted(() => ({ current: [] as unknown[] })) + +vi.mock('@/hooks/queries/tables', () => ({ + useTablesList: () => ({ data: mockTables.current }), +})) +vi.mock('@/hooks/queries/workspace-files', () => ({ + useWorkspaceFiles: () => ({ data: [] }), +})) +vi.mock('@/hooks/queries/kb/knowledge', () => ({ + useKnowledgeBasesQuery: () => ({ data: [] }), +})) +vi.mock('@/hooks/queries/folders', () => ({ + useFolderMap: () => ({ data: {} }), +})) + vi.mock('@/hooks/use-settings-navigation', () => ({ useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), })) @@ -518,11 +537,9 @@ describe('SearchModal', () => { }) it('hoists a module page’s actions and its entity section directly under the Sim group', async () => { - const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }] + mockTables.current = [{ id: 'table-1', name: 'Leads', folderId: null }] await act(async () => { - root.render( - - ) + root.render() }) const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index de345848b10..aeff9869682 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -43,10 +43,12 @@ import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { getFolderPathNames } from '@/lib/folders/tree' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { CommandFadedList, @@ -86,8 +88,13 @@ import { CMDK_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { useFolderMap } from '@/hooks/queries/folders' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import type { WorkflowFolder } from '@/stores/folders/types' import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' @@ -101,6 +108,9 @@ const logger = createLogger('SearchModal') export const MAX_BROWSE_RESULTS = Number.POSITIVE_INFINITY const MAX_SEARCH_RESULTS = 50 +/** Stable empty default so a pending folder map does not remount the memos below. */ +const EMPTY_FOLDER_MAP: Record = {} + export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' type SearchModalContentProps = Omit @@ -121,9 +131,6 @@ function SearchModalContent({ workflows = [], workspaces = [], chats = [], - tables = [], - files = [], - knowledgeBases = [], logs = [], integrations = [], connectedAccounts = [], @@ -157,6 +164,83 @@ function SearchModalContent({ const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data) + /** + * Read here rather than passed down from the sidebar. This component mounts only while the + * palette is open, so these workspace-wide lists are fetched — and registered in the query + * cache — only then. The sidebar renders on every workspace route, so holding them there put + * three lists and two folder maps in the cache on routes that never display them, and a + * registered key also blocks a page from seeding it during the server render. + */ + const { data: fetchedTables = [] } = useTablesList(workspaceId, 'active', { + enabled: !permissionConfig.hideTablesTab, + }) + const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId, 'active', { + enabled: !permissionConfig.hideFilesTab, + }) + const { data: fetchedKnowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, { + enabled: !permissionConfig.hideKnowledgeBaseTab, + }) + const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + permissionConfig.hideTablesTab ? undefined : workspaceId, + 'table' + ) + const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId, + 'knowledge_base' + ) + + /** + * The hidden-tab checks are repeated here, not left to `enabled`. A disabled query still + * returns whatever is already cached — another surface may have filled it, or the permission + * config may have flipped after it did — so gating only the fetch would let the palette list + * entities a permission group hides. + */ + const tables = useMemo( + () => + permissionConfig.hideTablesTab + ? [] + : fetchedTables.map((t) => ({ + id: t.id, + name: t.name, + href: `/workspace/${workspaceId}/tables/${t.id}`, + folderPath: getFolderPathNames(tableFolderMap, t.folderId), + })), + [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab] + ) + + const files = useMemo( + () => + permissionConfig.hideFilesTab + ? [] + : fetchedFiles.map((f) => ({ + id: f.id, + name: f.name, + href: `/workspace/${workspaceId}/files/${f.id}`, + folderPath: f.folderPath + ? parseWorkspaceFileFolderDisplayPath(f.folderPath) + : undefined, + })), + [fetchedFiles, workspaceId, permissionConfig.hideFilesTab] + ) + + const knowledgeBases = useMemo( + () => + permissionConfig.hideKnowledgeBaseTab + ? [] + : fetchedKnowledgeBases.map((kb) => ({ + id: kb.id, + name: kb.name, + href: `/workspace/${workspaceId}/knowledge/${kb.id}`, + folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId), + })), + [ + fetchedKnowledgeBases, + knowledgeBaseFolderMap, + workspaceId, + permissionConfig.hideKnowledgeBaseTab, + ] + ) + const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) }, []) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 7403ae52c85..35bfd2c12d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -166,9 +166,12 @@ export interface SearchModalProps { workflows?: WorkflowItem[] workspaces?: WorkspaceItem[] chats?: TaskItem[] - tables?: FolderedItem[] - files?: FileItem[] - knowledgeBases?: FolderedItem[] + /** + * Tables, files, and knowledge bases are NOT passed in: the content component reads them + * itself, so those three workspace-wide lists are queried only while the palette is open. + * The sidebar renders on every workspace route, so fetching them there registered the keys + * in the cache on routes that never show them. + */ logs?: LogItem[] integrations?: IntegrationSearchItem[] connectedAccounts?: IntegrationSearchItem[] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 6a6824eae68..94dafe2a8b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -43,7 +43,6 @@ import { isChatEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -100,7 +99,6 @@ import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useFolderMap, useFolders } from '@/hooks/queries/folders' -import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { type LogFilters, useLogsList } from '@/hooks/queries/logs' import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats' import { @@ -112,10 +110,8 @@ import { useRenameMothershipChat, useSetMothershipChatPinned, } from '@/hooks/queries/mothership-chats' -import { useTablesList } from '@/hooks/queries/tables' import { useUpdateWorkflow } from '@/hooks/queries/workflows' import type { Workspace } from '@/hooks/queries/workspace' -import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -600,16 +596,6 @@ export const Sidebar = memo(function Sidebar({ useFolders(workspaceId) const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId) - // Tables and knowledge bases keep their folders in the generic folder tree, - // keyed by resource type, so each needs its own map to resolve a path. - const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( - permissionConfig.hideTablesTab ? undefined : workspaceId, - 'table' - ) - const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( - permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId, - 'knowledge_base' - ) const updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( @@ -903,56 +889,6 @@ export const Sidebar = memo(function Sidebar({ [fetchedChats, workspaceId] ) - const { data: fetchedTables = [] } = useTablesList(workspaceId) - const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId) - const { data: fetchedKnowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId) - - const searchModalTables = useMemo( - () => - permissionConfig.hideTablesTab - ? [] - : fetchedTables.map((t) => ({ - id: t.id, - name: t.name, - href: `/workspace/${workspaceId}/tables/${t.id}`, - folderPath: getFolderPathNames(tableFolderMap, t.folderId), - })), - [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab] - ) - - const searchModalFiles = useMemo( - () => - permissionConfig.hideFilesTab - ? [] - : fetchedFiles.map((f) => ({ - id: f.id, - name: f.name, - href: `/workspace/${workspaceId}/files/${f.id}`, - folderPath: f.folderPath - ? parseWorkspaceFileFolderDisplayPath(f.folderPath) - : undefined, - })), - [fetchedFiles, workspaceId, permissionConfig.hideFilesTab] - ) - - const searchModalKnowledgeBases = useMemo( - () => - permissionConfig.hideKnowledgeBaseTab - ? [] - : fetchedKnowledgeBases.map((kb) => ({ - id: kb.id, - name: kb.name, - href: `/workspace/${workspaceId}/knowledge/${kb.id}`, - folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId), - })), - [ - fetchedKnowledgeBases, - knowledgeBaseFolderMap, - workspaceId, - permissionConfig.hideKnowledgeBaseTab, - ] - ) - const chatIds = useMemo(() => chats.map((t) => t.id), [chats]) const { selectedChats, handleChatClick } = useChatSelection({ chatIds }) @@ -1928,9 +1864,6 @@ export const Sidebar = memo(function Sidebar({ workflows={searchModalWorkflows} workspaces={searchModalWorkspaces} chats={chats} - tables={searchModalTables} - files={searchModalFiles} - knowledgeBases={searchModalKnowledgeBases} logs={searchModalLogs} integrations={searchModalIntegrations} connectedAccounts={searchModalConnectedAccounts}