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
37 changes: 27 additions & 10 deletions apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'

export const metadata: Metadata = {
Expand All @@ -17,18 +22,30 @@ interface ChatPageProps {
}

export default async function ChatPage({ params }: ChatPageProps) {
// The layout 404s too, but pages and layouts resolve concurrently — without this
// the prefetch below still fires on its way out.
if (!isChatEnabled) {
notFound()
}

const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
const userId = session?.user?.id
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
const queryClient = getQueryClient()
const [tableViewsEnabled] = await Promise.all([
resolveTableViewsEnabled(workspaceId, userId),
prefetchHomeSurface(queryClient, workspaceId, userId),
])
Comment thread
waleedlatif1 marked this conversation as resolved.
return (
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
</HydrationBoundary>
)
}
12 changes: 4 additions & 8 deletions apps/sim/app/workspace/[workspaceId]/files/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { listWorkspaceFileFoldersContract } from '@/lib/api/contracts/workspace-
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome'
import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
import {
WORKSPACE_FILE_FOLDERS_STALE_TIME,
workspaceFileFolderKeys,
Expand All @@ -15,14 +16,8 @@ import {
* the Owner column — under the same query keys their client hooks (`useWorkspaceFileFolders`) use
* (scope `active`), so the browser paints populated on first render.
*
* The FILE LIST itself is deliberately not here: the sidebar reads it on every workspace route, so
* it is seeded by `prefetchWorkspaceSidebar` in the layout — the only boundary that renders
* before the sidebar registers the query. Prefetching it again here would re-read it per request
* and still not reach the server render (`HydrationBoundary` defers an already-seen query to an
* effect, which SSR never runs). See the note on that entry. The layout declines to seed a
* workspace whose file list exceeds its payload budget; recovering those here would mean
* mirroring that budget check inversely, since an unconditional prefetch would re-read and
* duplicate the entry for every workspace under the budget.
* The file list is seeded here rather than in the layout so only the routes that render it pay for
* it. See {@link seedWorkspaceFiles} for why a large workspace seeds nothing at all.
*
* Folders and the chrome reads all go through the data layer, shaped to their route contracts so a
* hydrated entry matches a client fetch.
Expand Down Expand Up @@ -58,5 +53,6 @@ export async function prefetchFilesBrowser(
staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME,
}),
prefetchResourceListChrome(queryClient, workspaceId, 'file', userId),
seedWorkspaceFiles(queryClient, workspaceId),
])
}
27 changes: 17 additions & 10 deletions apps/sim/app/workspace/[workspaceId]/home/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { prefetchHomeSurface } from '@/app/workspace/[workspaceId]/home/prefetch'
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
import { Home } from './home'
import { HomeFallback } from './home-fallback'
Expand All @@ -20,19 +23,23 @@ export default async function HomePage({ params }: { params: Promise<{ workspace
redirect(`/workspace/${workspaceId}`)
}

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

return (
<Suspense fallback={<HomeFallback />}>
<Home userName={session?.user?.name} userId={userId} tableViewsEnabled={tableViewsEnabled} />
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<HomeFallback />}>
<Home
userName={session?.user?.name}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
</HydrationBoundary>
)
}
27 changes: 27 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/prefetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { QueryClient } from '@tanstack/react-query'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'

/**
* Prefetches what the Home surface needs on top of the workspace layout's own prefetch.
*
* Home reads the workspace file list on mount (resource tabs, mentions, the resource picker), so
* the list is seeded by the routes that render Home rather than by the layout: seeding it in the
* layout would pay for it on every workspace route, including the ones that never read it.
*
* The seed carries no authorization of its own, so the viewer is proved first. This reuses the
* layout's `cache`d host-context lookup rather than re-deriving the permission, so it costs no
* additional queries; a viewer without access caches nothing and the client fetch reaches the
* route for the real 403.
*/
export async function prefetchHomeSurface(
queryClient: QueryClient,
workspaceId: string,
userId: string | undefined
): Promise<void> {
if (!userId) return
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
if (!hostContext) return

await seedWorkspaceFiles(queryClient, workspaceId)
}
67 changes: 15 additions & 52 deletions apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,7 @@ vi.mock('@sim/emcn', () => ({

import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
import {
prefetchWorkspaceSidebar,
WORKSPACE_FILE_SEED_MAX,
} from '@/app/workspace/[workspaceId]/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 Down Expand Up @@ -357,19 +354,18 @@ describe('workspace list prefetches', () => {
})

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

await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID)

expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
})

/**
Expand Down Expand Up @@ -514,48 +510,15 @@ describe('workspace list prefetches', () => {
})

/**
* The file list is seeded on every workspace route, so it is the one entry whose size
* scales with a workspace's content on routes that never read it. The budget is passed
* down rather than applied here, so the read can stop before the share join.
*/
it('seeds the file list, bounded by the document payload budget', async () => {
const files = [{ id: 'file-1', name: 'a.txt' }]
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
const client = makeClient()

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

expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
maxRows: WORKSPACE_FILE_SEED_MAX,
/** A failed read must reach the catch, not degrade to a cached empty list. */
throwOnError: true,
})
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
})

/**
* The load-bearing half of the budget: a workspace over it seeds NOTHING rather than the
* prefix that was read. The sidebar search filters this list client-side and the Files
* browser renders it as the workspace's files, so a truncated seed would silently hide
* files — the client fetch must reach the route for the complete list instead.
* The file list belongs to the pages that render it, not to every workspace route. A sidebar
* seed would charge the workflow editor, logs, and settings for a read none of them make.
*/
it('seeds nothing when the workspace exceeds the budget', async () => {
mockListWorkspaceFilesWithShares.mockResolvedValue(null)
it('does not read the workspace file list', async () => {
const client = makeClient()

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

expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
})

/** A failed file read is an optimization loss, not a render failure. */
it('does not throw when the file read rejects, and seeds no files', async () => {
mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
const client = makeClient()

await expect(
prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null)
).resolves.toBeUndefined()
expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled()
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
})

Expand Down Expand Up @@ -591,9 +554,9 @@ describe('workspace list prefetches', () => {
],
[
/**
* 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.
* Asserted against the folder key: the file list is seeded rather than prefetched, so
* a rejecting read leaves that key empty by design and could not distinguish a
* swallowed failure from a function that did nothing.
*/
'prefetchFilesBrowser',
(client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockListWorkspaceFilesWithShares } = vi.hoisted(() => ({
mockListWorkspaceFilesWithShares: vi.fn(),
}))

vi.mock('@/lib/workspace-files/queries', () => ({
listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares,
}))

/** The key factory lives in a `'use client'` module that pulls emcn's CSS at import. */
vi.mock('@sim/emcn', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))

import {
seedWorkspaceFiles,
WORKSPACE_FILE_SEED_MAX,
} from '@/app/workspace/[workspaceId]/lib/seed-workspace-files'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'

const WORKSPACE_ID = 'ws-123'

function makeClient() {
const store = new Map<string, unknown>()
return {
setQueryData: (key: readonly unknown[], value: unknown) =>
store.set(JSON.stringify(key), value),
getQueryData: (key: readonly unknown[]) => store.get(JSON.stringify(key)),
} as never as import('@tanstack/react-query').QueryClient & {
getQueryData: (key: readonly unknown[]) => unknown
}
}

describe('seedWorkspaceFiles', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('seeds the file list, bounded by the document payload budget', async () => {
const files = [{ id: 'file-1', name: 'a.txt' }]
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
const client = makeClient()

await seedWorkspaceFiles(client, WORKSPACE_ID)

expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active', {
maxRows: WORKSPACE_FILE_SEED_MAX,
/** A failed read must reach the catch, not degrade to a cached empty list. */
throwOnError: true,
})
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
})

/**
* A workspace over the budget seeds NOTHING rather than the prefix that was read: the
* Files browser renders this list as the workspace's files, so a truncated seed would
* silently hide some. The client fetch reaches the route for the complete list instead.
*/
it('seeds nothing when the workspace exceeds the budget', async () => {
mockListWorkspaceFilesWithShares.mockResolvedValue(null)
const client = makeClient()

await seedWorkspaceFiles(client, WORKSPACE_ID)

expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
})

/** A failed read is an optimization loss, not a render failure. */
it('does not throw when the read rejects, and seeds nothing', async () => {
mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500'))
const client = makeClient()

await expect(seedWorkspaceFiles(client, WORKSPACE_ID)).resolves.toBeUndefined()
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
})
})
48 changes: 48 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/lib/seed-workspace-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { QueryClient } from '@tanstack/react-query'
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'

const logger = createLogger('SeedWorkspaceFiles')

/**
* How many files a page is willing to inline into its document. At ~500 bytes of JSON per
* file this budgets the entry at ~150 KB.
*
* A workspace above the budget seeds NOTHING rather than a prefix: the Files browser
* renders this list as the workspace's files, so a truncated seed would silently hide some.
*/
export const WORKSPACE_FILE_SEED_MAX = 300

/**
* Seeds the workspace's file list for the pages that render it.
*
* Seeded rather than prefetched so it can decline to create an entry at all above
* {@link WORKSPACE_FILE_SEED_MAX} — `prefetchQuery` always creates one, and a partial
* entry would be read as the whole list. Parsed through the route's response contract, so
* a seeded entry matches what a client fetch caches.
*/
export async function seedWorkspaceFiles(
queryClient: QueryClient,
workspaceId: string
): Promise<void> {
try {
const files = await listWorkspaceFilesWithShares(workspaceId, 'active', {
maxRows: WORKSPACE_FILE_SEED_MAX,
/**
* A failed read must reach the catch below, not degrade to an empty list: seeding
* `[]` would cache "this workspace has no files" as authoritative for the entry's
* lifetime, which is worse than seeding nothing and letting the client fetch.
*/
throwOnError: true,
})
if (!files) return
queryClient.setQueryData(workspaceFilesKeys.list(workspaceId, 'active'), files)
} catch (error) {
/** Optimization only: the client fetch reaches the route instead. */
logger.warn('Workspace file list seed failed; client will fetch', {
error: getErrorMessage(error),
})
}
}
Loading
Loading