Skip to content

Commit 245df29

Browse files
committed
refactor(prefetch): give the workspace-file seed its own module and tests
1 parent 85c8451 commit 245df29

5 files changed

Lines changed: 149 additions & 83 deletions

File tree

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,10 @@ import {
1515
* the Owner column — under the same query keys their client hooks (`useWorkspaceFileFolders`) use
1616
* (scope `active`), so the browser paints populated on first render.
1717
*
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.
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.
2622
*
2723
* Folders and the chrome reads all go through the data layer, shaped to their route contracts so a
2824
* hydrated entry matches a client fetch.

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

Lines changed: 8 additions & 33 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,11 +354,10 @@ 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 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.
365361
*/
366362
it('leaves the file list to the layout rather than re-reading it per page', async () => {
367363
const client = makeClient()
@@ -514,40 +510,19 @@ 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.
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.
520515
*/
521-
it('seeds the file list, bounded by the document payload budget', async () => {
516+
it('seeds the workspace file list', async () => {
522517
const files = [{ id: 'file-1', name: 'a.txt' }]
523518
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
524519
const client = makeClient()
525520

526521
await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
527522

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-
})
533523
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
534524
})
535525

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.
541-
*/
542-
it('seeds nothing when the workspace exceeds the budget', async () => {
543-
mockListWorkspaceFilesWithShares.mockResolvedValue(null)
544-
const client = makeClient()
545-
546-
await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
547-
548-
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
549-
})
550-
551526
/** A failed file read is an optimization loss, not a render failure. */
552527
it('does not throw when the file read rejects, and seeds no files', async () => {
553528
mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
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+
}

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

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
66
import { isChatEnabled } from '@/lib/core/config/env-flags'
77
import { getUserProfile } from '@/lib/users/queries'
88
import { listWorkflowsForUser } from '@/lib/workflows/queries'
9-
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
109
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
1110
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
1211
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
1312
import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders'
13+
import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
1414
import {
1515
MOTHERSHIP_CHAT_LIST_STALE_TIME,
1616
mapChat,
@@ -25,7 +25,6 @@ import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
2525
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
2626
import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query'
2727
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
28-
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
2928
import {
3029
WORKSPACE_HOST_CONTEXT_STALE_TIME,
3130
workspaceHostKeys,
@@ -92,46 +91,6 @@ async function seedWorkspaceList(
9291
}
9392
}
9493

95-
/**
96-
* How many files the layout is willing to inline into the document. Seeded on EVERY
97-
* workspace route, so at ~500 bytes of JSON per file this budgets the entry at ~150 KB.
98-
*
99-
* A workspace above the budget seeds NOTHING rather than a prefix: the sidebar filters
100-
* this list client-side, so a truncated seed would silently hide files.
101-
*/
102-
export const WORKSPACE_FILE_SEED_MAX = 300
103-
104-
/**
105-
* Seeds the workspace's file list, which sidebar chrome registers on EVERY workspace
106-
* route. It must be seeded HERE, not by the Files pages: `HydrationBoundary` defers a
107-
* query the cache has already seen to a `useEffect`, which SSR never runs.
108-
*
109-
* Seeded rather than prefetched so it can decline to create an entry at all above
110-
* {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial
111-
* entry would be read as the whole list. Parsed through the route's response contract.
112-
*/
113-
async function seedWorkspaceFiles(queryClient: QueryClient, workspaceId: string): Promise<void> {
114-
try {
115-
const files = await listWorkspaceFilesWithShares(workspaceId, 'active', {
116-
maxRows: WORKSPACE_FILE_SEED_MAX,
117-
/**
118-
* A failed read must reach the catch below, not degrade to an empty list: seeding
119-
* `[]` would cache "this workspace has no files" as authoritative for the entry's
120-
* lifetime, which is worse than seeding nothing and letting the client fetch.
121-
*/
122-
throwOnError: true,
123-
})
124-
if (!files) return
125-
queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files)
126-
} catch (error) {
127-
/** Optimization only: the client fetch reaches the route instead. Logged so drift between
128-
* this read and the contract's response schema doesn't degrade silently into a waterfall. */
129-
logger.warn('Workspace file list seed failed; client will fetch', {
130-
error: getErrorMessage(error),
131-
})
132-
}
133-
}
134-
13594
/**
13695
* Prefetches the sidebar's workflow, chat, folder, workspace-permissions,
13796
* workspace, and viewer-profile reads for a workspace and stores them under the
@@ -190,6 +149,14 @@ export async function prefetchWorkspaceSidebar(
190149
]
191150
: []),
192151
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+
*/
193160
seedWorkspaceFiles(queryClient, workspaceId),
194161
queryClient.prefetchQuery({
195162
queryKey: workspaceKeys.permissions(workspaceId),

0 commit comments

Comments
 (0)