diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 1bfaf144acf..7aad80a5bca 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -36,6 +36,8 @@ export async function prefetchKnowledgeBases( workspaceId: string, userId: string | undefined ): Promise { + if (!userId) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: knowledgeKeys.list(workspaceId, 'active'), diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 32309a08be9..dee79520936 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -12,6 +12,11 @@ const { mockListFoldersForWorkspace, mockListInternalKnowledgeBases, mockListPinnedItemsForUser, + mockListWorkflowsForUser, + mockListWorkspacesForViewer, + mockGetUserProfile, + mockGetWorkspacePermissions, + mockListMothershipChats, mockListTables, mockListWorkspaceFileFolders, mockListWorkspaceFilesWithShares, @@ -23,6 +28,11 @@ const { mockListFoldersForWorkspace: vi.fn(), mockListInternalKnowledgeBases: vi.fn(), mockListPinnedItemsForUser: vi.fn(), + mockListWorkflowsForUser: vi.fn(), + mockListWorkspacesForViewer: vi.fn(), + mockGetUserProfile: vi.fn(), + mockGetWorkspacePermissions: vi.fn(), + mockListMothershipChats: vi.fn(), mockListTables: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), mockListWorkspaceFilesWithShares: vi.fn(), @@ -45,6 +55,19 @@ vi.mock('@/lib/pinned-items/queries', () => ({ })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceMemberProfiles: mockGetWorkspaceMemberProfiles, + getWorkspacePermissionsForAuthorizedViewer: mockGetWorkspacePermissions, +})) +vi.mock('@/lib/workflows/queries', () => ({ + listWorkflowsForUser: mockListWorkflowsForUser, +})) +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: mockListWorkspacesForViewer, +})) +vi.mock('@/lib/users/queries', () => ({ + getUserProfile: mockGetUserProfile, +})) +vi.mock('@/lib/copilot/chat/list-mothership-chats', () => ({ + listMothershipChats: mockListMothershipChats, })) vi.mock('@/lib/table/service', () => ({ listTables: mockListTables, @@ -74,6 +97,7 @@ vi.mock('@sim/emcn', () => ({ import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/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' @@ -98,6 +122,16 @@ describe('workspace list prefetches', () => { mockListWorkspaceFilesWithShares.mockResolvedValue([]) mockListWorkspaceFileFolders.mockResolvedValue([]) mockListPinnedItemsForUser.mockResolvedValue([]) + mockListWorkflowsForUser.mockResolvedValue([]) + mockGetUserProfile.mockResolvedValue({ id: USER_ID, name: 'Ada', email: 'a@b.c' }) + mockGetWorkspacePermissions.mockResolvedValue({ users: [] }) + mockListMothershipChats.mockResolvedValue([]) + mockListWorkspacesForViewer.mockResolvedValue({ + workspaces: [], + lastActiveWorkspaceId: null, + pinnedWorkspaceIds: [], + creationPolicy: null, + }) mockGetWorkspaceMemberProfiles.mockResolvedValue([]) mockListTables.mockResolvedValue([]) mockAuthenticate.mockResolvedValue({ kind: 'session', userId: USER_ID, sessionId: 'sess-1' }) @@ -366,6 +400,84 @@ describe('workspace list prefetches', () => { } }) + describe('prefetchWorkspaceSidebar / seedWorkspaceList', () => { + const HOST_CONTEXT = { + workspace: { id: WORKSPACE_ID }, + viewer: { permission: 'admin' }, + } as never + + const WORKSPACE_ROW = { + id: WORKSPACE_ID, + name: 'GTM', + ownerId: USER_ID, + organizationId: null, + workspaceMode: 'personal', + permissions: 'admin', + } + + const LIST_PAYLOAD = { + workspaces: [WORKSPACE_ROW], + lastActiveWorkspaceId: null, + pinnedWorkspaceIds: [], + creationPolicy: null, + } + + /** + * The load-bearing contract: an empty list must leave the key UNSET so the client + * fetch reaches `GET /api/workspaces`' default-workspace creation path. Seeding an + * empty array instead would suppress it and strand a brand-new viewer. + */ + it('seeds nothing when the viewer has no workspaces', async () => { + mockListWorkspacesForViewer.mockResolvedValue({ ...LIST_PAYLOAD, workspaces: [] }) + const client = makeClient() + + await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) + + expect(client.getQueryData(workspaceKeys.list('active'))).toBeUndefined() + }) + + it('seeds the workspace list when the viewer has one', async () => { + mockListWorkspacesForViewer.mockResolvedValue(LIST_PAYLOAD) + const client = makeClient() + + await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) + + const cached = client.getQueryData(workspaceKeys.list('active')) as + | { workspaces: Array<{ id: string }> } + | undefined + expect(cached).toBeDefined() + expect(cached?.workspaces.map((w) => w.id)).toEqual([WORKSPACE_ID]) + }) + + /** A failed seed is an optimization loss, not a render failure. */ + it('does not throw when the workspace read rejects, and seeds nothing', async () => { + mockListWorkspacesForViewer.mockRejectedValue(new Error('500')) + const client = makeClient() + + await expect( + prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) + ).resolves.toBeUndefined() + expect(client.getQueryData(workspaceKeys.list('active'))).toBeUndefined() + }) + + /** Guards the mismatch check that keeps one workspace's data out of another's cache. */ + it('seeds nothing when the host context is for a different workspace', async () => { + mockListWorkspacesForViewer.mockResolvedValue(LIST_PAYLOAD) + const client = makeClient() + + await prefetchWorkspaceSidebar( + client, + WORKSPACE_ID, + USER_ID, + { workspace: { id: 'other-ws' }, viewer: { permission: 'admin' } } as never, + null + ) + + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListWorkspacesForViewer).not.toHaveBeenCalled() + }) + }) + describe('graceful failure', () => { it.each([ [ @@ -379,9 +491,14 @@ describe('workspace list prefetches', () => { tableKeys.list(WORKSPACE_ID, 'active'), ], [ + /** + * 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. + */ 'prefetchFilesBrowser', (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), - workspaceFilesKeys.list(WORKSPACE_ID, 'active'), + workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'), ], ] as const)( '%s does not throw when the fetcher rejects (page still renders, client refetches)', @@ -393,6 +510,7 @@ describe('workspace list prefetches', () => { mockListInternalKnowledgeBases.mockRejectedValue(boom) mockListPinnedItemsForUser.mockRejectedValue(boom) mockGetWorkspaceMemberProfiles.mockRejectedValue(boom) + mockListWorkspaceFileFolders.mockRejectedValue(boom) const client = makeClient() await expect(prefetch(client)).resolves.toBeUndefined() diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 0023b34d851..c24198fb51f 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -4,13 +4,13 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' -import { listFoldersForWorkspace } from '@/lib/folders/queries' 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' +import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, @@ -21,7 +21,6 @@ import { USER_PROFILE_STALE_TIME, userProfileKeys, } from '@/hooks/queries/user-profile' -import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' 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' @@ -59,9 +58,8 @@ const logger = createLogger('WorkspacePrefetch') * Seeded rather than prefetched so the empty-list case can decline to create a * cache entry at all: the route's default-workspace creation path must run on * the client, and an entry — even an empty one — would suppress it. Expressing - * that as an absent seed keeps a normal state out of the error channel, where - * it previously cost a full second re-read (`retry: 1`) to re-derive an outcome - * already known. + * that as an absent seed also keeps a routine state out of the error channel, + * where it read as a failure rather than as "nothing to seed". */ async function seedWorkspaceList( queryClient: QueryClient, @@ -122,9 +120,7 @@ async function seedWorkspaceList( * to produce, without routing a normal state through the error channel. That * matters because only a settled query is dehydrated: an unawaited read would be * dropped from the payload entirely, so the switcher would waterfall on every - * cold load rather than paint populated. Seeding also skips the `retry` default, - * which previously ran the whole read a second time, a retry delay later, purely - * to re-derive an outcome already known. + * cold load rather than paint populated. */ export async function prefetchWorkspaceSidebar( queryClient: QueryClient, @@ -156,14 +152,7 @@ export async function prefetchWorkspaceSidebar( }), ] : []), - queryClient.prefetchQuery({ - queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), - queryFn: async () => { - const rows = await listFoldersForWorkspace(workspaceId, 'active', 'workflow') - return rows.map(mapFolder) - }, - staleTime: FOLDER_LIST_STALE_TIME, - }), + prefetchResourceFolders(queryClient, workspaceId, 'workflow', userId), /** * The sidebar reads the workspace's files for its search modal, on EVERY workspace route — so this * query is registered by sidebar chrome before any page renders. That ordering is why it has to be