Skip to content

Commit db13084

Browse files
committed
perf(prefetch): seed the file list on the pages that render it, not every route
1 parent 72bc2a4 commit db13084

6 files changed

Lines changed: 81 additions & 60 deletions

File tree

Lines changed: 19 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 { getSession } from '@/lib/auth'
5+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
46
import { Home } from '@/app/workspace/[workspaceId]/home/home'
57
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
8+
import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
69
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
710

811
export const metadata: Metadata = {
@@ -19,16 +22,22 @@ interface ChatPageProps {
1922
export default async function ChatPage({ params }: ChatPageProps) {
2023
const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
2124
const userId = session?.user?.id
22-
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
25+
const queryClient = getQueryClient()
26+
const [tableViewsEnabled] = await Promise.all([
27+
resolveTableViewsEnabled(workspaceId, userId),
28+
prefetchHomeSurface(queryClient, workspaceId, userId),
29+
])
2330
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>
31+
<HydrationBoundary state={dehydrate(queryClient)}>
32+
<Suspense fallback={<HomeFallback />}>
33+
<Home
34+
key={chatId}
35+
chatId={chatId}
36+
userName={session?.user?.name}
37+
userId={userId}
38+
tableViewsEnabled={tableViewsEnabled}
39+
/>
40+
</Suspense>
41+
</HydrationBoundary>
3342
)
3443
}

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

Lines changed: 4 additions & 4 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,10 +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 not here: the workspace layout seeds it, because only the first boundary
19-
* to touch a key can reach the server render — `HydrationBoundary` defers an already-registered
20-
* query to a `useEffect` SSR never runs, and the sidebar registers this key on every route even
21-
* with its own fetch disabled.
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.
2221
*
2322
* Folders and the chrome reads all go through the data layer, shaped to their route contracts so a
2423
* hydrated entry matches a client fetch.
@@ -54,5 +53,6 @@ export async function prefetchFilesBrowser(
5453
staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME,
5554
}),
5655
prefetchResourceListChrome(queryClient, workspaceId, 'file', userId),
56+
seedWorkspaceFiles(queryClient, workspaceId),
5757
])
5858
}
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: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -354,18 +354,18 @@ describe('workspace list prefetches', () => {
354354
})
355355

356356
/**
357-
* The FILE LIST is deliberately not primed here — `prefetchWorkspaceSidebar` owns it. The
358-
* sidebar registers that query on every workspace route (a disabled query still registers), and
359-
* `HydrationBoundary` hands an already-registered query to a `useEffect` SSR never runs, so a
360-
* page-level seed costs a read per render and still cannot reach the server render.
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.
361360
*/
362-
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)
363364
const client = makeClient()
364365

365366
await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID)
366367

367-
expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
368-
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
368+
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
369369
})
370370

371371
/**
@@ -510,27 +510,15 @@ describe('workspace list prefetches', () => {
510510
})
511511

512512
/**
513-
* The layout is the only boundary that can reach the server render for this key, so it is the
514-
* one that seeds it — see the note on the entry itself.
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.
515515
*/
516-
it('seeds the workspace file list', async () => {
517-
const files = [{ id: 'file-1', name: 'a.txt' }]
518-
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
516+
it('does not read the workspace file list', async () => {
519517
const client = makeClient()
520518

521519
await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
522520

523-
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
524-
})
525-
526-
/** A failed file read is an optimization loss, not a render failure. */
527-
it('does not throw when the file read rejects, and seeds no files', async () => {
528-
mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
529-
const client = makeClient()
530-
531-
await expect(
532-
prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
533-
).resolves.toBeUndefined()
521+
expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
534522
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
535523
})
536524

@@ -566,9 +554,9 @@ describe('workspace list prefetches', () => {
566554
],
567555
[
568556
/**
569-
* Asserted against the folder key, not the file list: `prefetchFilesBrowser`
570-
* deliberately never seeds `workspaceFilesKeys` (the layout owns it), so an
571-
* 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.
572560
*/
573561
'prefetchFilesBrowser',
574562
(client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID),

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
1010
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
1111
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
1212
import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders'
13-
import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
1413
import {
1514
MOTHERSHIP_CHAT_LIST_STALE_TIME,
1615
mapChat,
@@ -149,15 +148,6 @@ export async function prefetchWorkspaceSidebar(
149148
]
150149
: []),
151150
prefetchResourceFolders(queryClient, workspaceId, 'workflow', userId),
152-
/**
153-
* Seeded from the layout, not from the pages that render the list. `enabled: false` stops the
154-
* sidebar's query from FETCHING but not from registering: `useQuery` builds its observer
155-
* unconditionally, and the observer's constructor calls `queryCache.build()`, which adds the
156-
* key. `HydrationBoundary` then defers an already-registered query to a `useEffect` SSR never
157-
* runs — so only the first boundary to touch the key can reach the server render, and that is
158-
* this one.
159-
*/
160-
seedWorkspaceFiles(queryClient, workspaceId),
161151
queryClient.prefetchQuery({
162152
queryKey: workspaceKeys.permissions(workspaceId),
163153
queryFn: () =>

0 commit comments

Comments
 (0)