Skip to content

Commit 0758df3

Browse files
authored
perf(workspace): stop the sidebar fetching palette data on every route (#6670)
* refactor(prefetch): give the workspace-file seed its own module and tests * perf(sidebar): let the palette fetch its own lists, so closed routes never register them * perf(prefetch): seed the file list on the pages that render it, not every route * fix(chat): guard the chat page prefetch behind the chat flag
1 parent 49ec9a9 commit 0758df3

12 files changed

Lines changed: 332 additions & 200 deletions

File tree

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { Suspense } from 'react'
2+
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
23
import type { Metadata } from 'next'
4+
import { notFound } from 'next/navigation'
35
import { getSession } from '@/lib/auth'
6+
import { isChatEnabled } from '@/lib/core/config/env-flags'
7+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
48
import { Home } from '@/app/workspace/[workspaceId]/home/home'
59
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
10+
import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
611
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
712

813
export const metadata: Metadata = {
@@ -17,18 +22,30 @@ interface ChatPageProps {
1722
}
1823

1924
export default async function ChatPage({ params }: ChatPageProps) {
25+
// The layout 404s too, but pages and layouts resolve concurrently — without this
26+
// the prefetch below still fires on its way out.
27+
if (!isChatEnabled) {
28+
notFound()
29+
}
30+
2031
const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
2132
const userId = session?.user?.id
22-
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
33+
const queryClient = getQueryClient()
34+
const [tableViewsEnabled] = await Promise.all([
35+
resolveTableViewsEnabled(workspaceId, userId),
36+
prefetchHomeSurface(queryClient, workspaceId, userId),
37+
])
2338
return (
24-
<Suspense fallback={<HomeFallback />}>
25-
<Home
26-
key={chatId}
27-
chatId={chatId}
28-
userName={session?.user?.name}
29-
userId={userId}
30-
tableViewsEnabled={tableViewsEnabled}
31-
/>
32-
</Suspense>
39+
<HydrationBoundary state={dehydrate(queryClient)}>
40+
<Suspense fallback={<HomeFallback />}>
41+
<Home
42+
key={chatId}
43+
chatId={chatId}
44+
userName={session?.user?.name}
45+
userId={userId}
46+
tableViewsEnabled={tableViewsEnabled}
47+
/>
48+
</Suspense>
49+
</HydrationBoundary>
3350
)
3451
}

apps/sim/app/workspace/[workspaceId]/files/prefetch.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { listWorkspaceFileFoldersContract } from '@/lib/api/contracts/workspace-
33
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
44
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
55
import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome'
6+
import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
67
import {
78
WORKSPACE_FILE_FOLDERS_STALE_TIME,
89
workspaceFileFolderKeys,
@@ -15,14 +16,8 @@ import {
1516
* the Owner column — under the same query keys their client hooks (`useWorkspaceFileFolders`) use
1617
* (scope `active`), so the browser paints populated on first render.
1718
*
18-
* The FILE LIST itself is deliberately not here: the sidebar reads it on every workspace route, so
19-
* it is seeded by `prefetchWorkspaceSidebar` in the layout — the only boundary that renders
20-
* before the sidebar registers the query. Prefetching it again here would re-read it per request
21-
* and still not reach the server render (`HydrationBoundary` defers an already-seen query to an
22-
* effect, which SSR never runs). See the note on that entry. The layout declines to seed a
23-
* workspace whose file list exceeds its payload budget; recovering those here would mean
24-
* mirroring that budget check inversely, since an unconditional prefetch would re-read and
25-
* duplicate the entry for every workspace under the budget.
19+
* The file list is seeded here rather than in the layout so only the routes that render it pay for
20+
* it. See {@link seedWorkspaceFiles} for why a large workspace seeds nothing at all.
2621
*
2722
* Folders and the chrome reads all go through the data layer, shaped to their route contracts so a
2823
* hydrated entry matches a client fetch.
@@ -58,5 +53,6 @@ export async function prefetchFilesBrowser(
5853
staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME,
5954
}),
6055
prefetchResourceListChrome(queryClient, workspaceId, 'file', userId),
56+
seedWorkspaceFiles(queryClient, workspaceId),
6157
])
6258
}
Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { Suspense } from 'react'
2+
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
23
import type { Metadata } from 'next'
34
import { redirect } from 'next/navigation'
45
import { getSession } from '@/lib/auth'
56
import { isChatEnabled } from '@/lib/core/config/env-flags'
7+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
8+
import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
69
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
710
import { Home } from './home'
811
import { HomeFallback } from './home-fallback'
@@ -20,19 +23,23 @@ export default async function HomePage({ params }: { params: Promise<{ workspace
2023
redirect(`/workspace/${workspaceId}`)
2124
}
2225

23-
/**
24-
* Home prefetches nothing of its own. Both lists it reads — workflow folders and
25-
* the workspace file list — are hydrated by `prefetchWorkspaceSidebar` in the
26-
* layout under the same keys, and re-reading them here would cost a second query
27-
* per request without reaching the server render.
28-
*/
2926
const session = await getSession()
3027
const userId = session?.user?.id
31-
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
28+
const queryClient = getQueryClient()
29+
const [tableViewsEnabled] = await Promise.all([
30+
resolveTableViewsEnabled(workspaceId, userId),
31+
prefetchHomeSurface(queryClient, workspaceId, userId),
32+
])
3233

3334
return (
34-
<Suspense fallback={<HomeFallback />}>
35-
<Home userName={session?.user?.name} userId={userId} tableViewsEnabled={tableViewsEnabled} />
36-
</Suspense>
35+
<HydrationBoundary state={dehydrate(queryClient)}>
36+
<Suspense fallback={<HomeFallback />}>
37+
<Home
38+
userName={session?.user?.name}
39+
userId={userId}
40+
tableViewsEnabled={tableViewsEnabled}
41+
/>
42+
</Suspense>
43+
</HydrationBoundary>
3744
)
3845
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { QueryClient } from '@tanstack/react-query'
2+
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
3+
import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
4+
5+
/**
6+
* Prefetches what the Home surface needs on top of the workspace layout's own prefetch.
7+
*
8+
* Home reads the workspace file list on mount (resource tabs, mentions, the resource picker), so
9+
* the list is seeded by the routes that render Home rather than by the layout: seeding it in the
10+
* layout would pay for it on every workspace route, including the ones that never read it.
11+
*
12+
* The seed carries no authorization of its own, so the viewer is proved first. This reuses the
13+
* layout's `cache`d host-context lookup rather than re-deriving the permission, so it costs no
14+
* additional queries; a viewer without access caches nothing and the client fetch reaches the
15+
* route for the real 403.
16+
*/
17+
export async function prefetchHomeSurface(
18+
queryClient: QueryClient,
19+
workspaceId: string,
20+
userId: string | undefined
21+
): Promise<void> {
22+
if (!userId) return
23+
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
24+
if (!hostContext) return
25+
26+
await seedWorkspaceFiles(queryClient, workspaceId)
27+
}

apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts

Lines changed: 15 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,7 @@ vi.mock('@sim/emcn', () => ({
9797

9898
import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
9999
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
100-
import {
101-
prefetchWorkspaceSidebar,
102-
WORKSPACE_FILE_SEED_MAX,
103-
} from '@/app/workspace/[workspaceId]/prefetch'
100+
import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
104101
import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch'
105102
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
106103
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
@@ -357,19 +354,18 @@ describe('workspace list prefetches', () => {
357354
})
358355

359356
/**
360-
* The FILE LIST is deliberately not primed here — `prefetchWorkspaceSidebar` owns it, because the
361-
* sidebar reads that query on every workspace route and therefore registers it before any page
362-
* renders. `HydrationBoundary` hands an already-seen query to a `useEffect`, which SSR never runs,
363-
* so a page-level prefetch of this key costs a request per render and still cannot reach the server
364-
* render. Restoring it here would reintroduce exactly that.
357+
* The file list is the browser's primary content, so it must be seeded by the page that
358+
* renders it — the layout no longer seeds it, which would have charged every workspace
359+
* route for a list only a few of them read.
365360
*/
366-
it('leaves the file list to the layout rather than re-reading it per page', async () => {
361+
it('seeds the file list the browser renders', async () => {
362+
const files = [{ id: 'file-1' }]
363+
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
367364
const client = makeClient()
368365

369366
await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID)
370367

371-
expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
372-
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
368+
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
373369
})
374370

375371
/**
@@ -514,48 +510,15 @@ describe('workspace list prefetches', () => {
514510
})
515511

516512
/**
517-
* The file list is seeded on every workspace route, so it is the one entry whose size
518-
* scales with a workspace's content on routes that never read it. The budget is passed
519-
* down rather than applied here, so the read can stop before the share join.
520-
*/
521-
it('seeds the file list, bounded by the document payload budget', async () => {
522-
const files = [{ id: 'file-1', name: 'a.txt' }]
523-
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
524-
const client = makeClient()
525-
526-
await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
527-
528-
expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
529-
maxRows: WORKSPACE_FILE_SEED_MAX,
530-
/** A failed read must reach the catch, not degrade to a cached empty list. */
531-
throwOnError: true,
532-
})
533-
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
534-
})
535-
536-
/**
537-
* The load-bearing half of the budget: a workspace over it seeds NOTHING rather than the
538-
* prefix that was read. The sidebar search filters this list client-side and the Files
539-
* browser renders it as the workspace's files, so a truncated seed would silently hide
540-
* files — the client fetch must reach the route for the complete list instead.
513+
* The file list belongs to the pages that render it, not to every workspace route. A sidebar
514+
* seed would charge the workflow editor, logs, and settings for a read none of them make.
541515
*/
542-
it('seeds nothing when the workspace exceeds the budget', async () => {
543-
mockListWorkspaceFilesWithShares.mockResolvedValue(null)
516+
it('does not read the workspace file list', async () => {
544517
const client = makeClient()
545518

546519
await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
547520

548-
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
549-
})
550-
551-
/** A failed file read is an optimization loss, not a render failure. */
552-
it('does not throw when the file read rejects, and seeds no files', async () => {
553-
mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
554-
const client = makeClient()
555-
556-
await expect(
557-
prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
558-
).resolves.toBeUndefined()
521+
expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
559522
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
560523
})
561524

@@ -591,9 +554,9 @@ describe('workspace list prefetches', () => {
591554
],
592555
[
593556
/**
594-
* Asserted against the folder key, not the file list: `prefetchFilesBrowser`
595-
* deliberately never seeds `workspaceFilesKeys` (the layout owns it), so an
596-
* assertion on that key would hold no matter what this function did.
557+
* Asserted against the folder key: the file list is seeded rather than prefetched, so
558+
* a rejecting read leaves that key empty by design and could not distinguish a
559+
* swallowed failure from a function that did nothing.
597560
*/
598561
'prefetchFilesBrowser',
599562
(client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID),
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockListWorkspaceFilesWithShares } = vi.hoisted(() => ({
7+
mockListWorkspaceFilesWithShares: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/workspace-files/queries', () => ({
11+
listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares,
12+
}))
13+
14+
/** The key factory lives in a `'use client'` module that pulls emcn's CSS at import. */
15+
vi.mock('@sim/emcn', () => ({
16+
toast: { success: vi.fn(), error: vi.fn() },
17+
}))
18+
19+
import {
20+
seedWorkspaceFiles,
21+
WORKSPACE_FILE_SEED_MAX,
22+
} from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
23+
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
24+
25+
const WORKSPACE_ID = 'ws-123'
26+
27+
function makeClient() {
28+
const store = new Map<string, unknown>()
29+
return {
30+
setQueryData: (key: readonly unknown[], value: unknown) =>
31+
store.set(JSON.stringify(key), value),
32+
getQueryData: (key: readonly unknown[]) => store.get(JSON.stringify(key)),
33+
} as never as import('@tanstack/react-query').QueryClient & {
34+
getQueryData: (key: readonly unknown[]) => unknown
35+
}
36+
}
37+
38+
describe('seedWorkspaceFiles', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
})
42+
43+
it('seeds the file list, bounded by the document payload budget', async () => {
44+
const files = [{ id: 'file-1', name: 'a.txt' }]
45+
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
46+
const client = makeClient()
47+
48+
await seedWorkspaceFiles(client, WORKSPACE_ID)
49+
50+
expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
51+
maxRows: WORKSPACE_FILE_SEED_MAX,
52+
/** A failed read must reach the catch, not degrade to a cached empty list. */
53+
throwOnError: true,
54+
})
55+
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
56+
})
57+
58+
/**
59+
* A workspace over the budget seeds NOTHING rather than the prefix that was read: the
60+
* Files browser renders this list as the workspace's files, so a truncated seed would
61+
* silently hide some. The client fetch reaches the route for the complete list instead.
62+
*/
63+
it('seeds nothing when the workspace exceeds the budget', async () => {
64+
mockListWorkspaceFilesWithShares.mockResolvedValue(null)
65+
const client = makeClient()
66+
67+
await seedWorkspaceFiles(client, WORKSPACE_ID)
68+
69+
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
70+
})
71+
72+
/** A failed read is an optimization loss, not a render failure. */
73+
it('does not throw when the read rejects, and seeds nothing', async () => {
74+
mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
75+
const client = makeClient()
76+
77+
await expect(seedWorkspaceFiles(client, WORKSPACE_ID)).resolves.toBeUndefined()
78+
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
79+
})
80+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { QueryClient } from '@tanstack/react-query'
4+
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
5+
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
6+
7+
const logger = createLogger('SeedWorkspaceFiles')
8+
9+
/**
10+
* How many files a page is willing to inline into its document. At ~500 bytes of JSON per
11+
* file this budgets the entry at ~150 KB.
12+
*
13+
* A workspace above the budget seeds NOTHING rather than a prefix: the Files browser
14+
* renders this list as the workspace's files, so a truncated seed would silently hide some.
15+
*/
16+
export const WORKSPACE_FILE_SEED_MAX = 300
17+
18+
/**
19+
* Seeds the workspace's file list for the pages that render it.
20+
*
21+
* Seeded rather than prefetched so it can decline to create an entry at all above
22+
* {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial
23+
* entry would be read as the whole list. Parsed through the route's response contract, so
24+
* a seeded entry matches what a client fetch caches.
25+
*/
26+
export async function seedWorkspaceFiles(
27+
queryClient: QueryClient,
28+
workspaceId: string
29+
): Promise<void> {
30+
try {
31+
const files = await listWorkspaceFilesWithShares(workspaceId, 'active', {
32+
maxRows: WORKSPACE_FILE_SEED_MAX,
33+
/**
34+
* A failed read must reach the catch below, not degrade to an empty list: seeding
35+
* `[]` would cache "this workspace has no files" as authoritative for the entry's
36+
* lifetime, which is worse than seeding nothing and letting the client fetch.
37+
*/
38+
throwOnError: true,
39+
})
40+
if (!files) return
41+
queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files)
42+
} catch (error) {
43+
/** Optimization only: the client fetch reaches the route instead. */
44+
logger.warn('Workspace file list seed failed; client will fetch', {
45+
error: getErrorMessage(error),
46+
})
47+
}
48+
}

0 commit comments

Comments
 (0)