Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export async function prefetchKnowledgeBases(
workspaceId: string,
userId: string | undefined
): Promise<void> {
if (!userId) return

await Promise.all([
queryClient.prefetchQuery({
queryKey: knowledgeKeys.list(workspaceId, 'active'),
Expand Down
120 changes: 119 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const {
mockListFoldersForWorkspace,
mockListInternalKnowledgeBases,
mockListPinnedItemsForUser,
mockListWorkflowsForUser,
mockListWorkspacesForViewer,
mockGetUserProfile,
mockGetWorkspacePermissions,
mockListMothershipChats,
mockListTables,
mockListWorkspaceFileFolders,
mockListWorkspaceFilesWithShares,
Expand All @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -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' })
Expand Down Expand Up @@ -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([
[
Expand All @@ -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)',
Expand All @@ -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()
Expand Down
21 changes: 5 additions & 16 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading