From f0881cc75f7b9272d31b04d18b00c73a19a5409e Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 24 Jun 2026 08:45:08 -0700 Subject: [PATCH] perf(workspace): server-prefetch home, knowledge, tables, and files list pages --- .../workspace/[workspaceId]/files/page.tsx | 18 +- .../workspace/[workspaceId]/files/prefetch.ts | 43 +++++ .../app/workspace/[workspaceId]/home/page.tsx | 20 ++- .../workspace/[workspaceId]/home/prefetch.ts | 48 +++++ .../[workspaceId]/knowledge/page.tsx | 20 ++- .../[workspaceId]/knowledge/prefetch.ts | 28 +++ .../lib/prefetch-internal-fetch.ts | 25 +++ .../[workspaceId]/lib/prefetch.test.ts | 169 ++++++++++++++++++ .../workspace/[workspaceId]/tables/page.tsx | 18 +- .../[workspaceId]/tables/prefetch.ts | 26 +++ apps/sim/hooks/queries/folders.ts | 10 +- 11 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/prefetch.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/prefetch.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index ab21f2f3b72..2ed876e4ba0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { Files } from './files' import FilesLoading from './loading' @@ -15,10 +18,17 @@ export const metadata: Metadata = { * table headers) so a suspend never shows a blank frame; the route-level * `loading.tsx` covers the navigation/chunk-load transition the same way. */ -export default function FilesPage() { +export default async function FilesPage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + await prefetchFilesBrowser(queryClient, workspaceId) + return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts new file mode 100644 index 00000000000..8780aa537fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -0,0 +1,43 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders' +import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +/** + * Prefetches the Files browser's two lists — workspace files and file folders — + * under the same query keys their client hooks (`useWorkspaceFiles`, + * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints + * populated on first render. + * + * Both payloads carry `Date` fields, so they go through their routes and cache + * the serialized wire shape — see {@link prefetchInternalJson}. + */ +export async function prefetchFilesBrowser( + queryClient: QueryClient, + workspaceId: string +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: workspaceFilesKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await prefetchInternalJson( + `/api/workspaces/${workspaceId}/files?scope=active` + ) + return data.success ? data.files : [] + }, + staleTime: 30 * 1000, + }), + queryClient.prefetchQuery({ + queryKey: workspaceFileFolderKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await prefetchInternalJson<{ folders?: WorkspaceFileFolderApi[] }>( + `/api/workspaces/${workspaceId}/files/folders?scope=active` + ) + return data.folders ?? [] + }, + staleTime: 30 * 1000, + }), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index e29acc640ee..13595d65398 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,6 +1,9 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { getSession } from '@/lib/auth' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' import { Home } from './home' import { HomeFallback } from './home-fallback' @@ -8,11 +11,20 @@ export const metadata: Metadata = { title: 'New chat', } -export default async function HomePage() { +export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) + const session = await getSession() + await listsPrefetch + return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts new file mode 100644 index 00000000000..956a9e95555 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { FolderApi } from '@/lib/api/contracts' +import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +/** + * Prefetches the home page's secondary lists — folders and workspace files — + * under the same query keys their client hooks (`useFolders`, + * `useWorkspaceFiles`) use, so the home view paints populated on first render. + * + * The workflow list (`workflowKeys.list(ws, 'active')`) is already hydrated by + * the workspace sidebar prefetch and is intentionally not repeated here. + * + * Folders are fetched through the route and mapped with the same `mapFolder` + * the hook applies, matching its cached shape (string dates → `Date`). Files + * carry `Date` fields, so they go through the route and cache the serialized + * wire shape — see {@link prefetchInternalJson}. + */ +export async function prefetchHomeLists( + queryClient: QueryClient, + workspaceId: string +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: folderKeys.list(workspaceId, 'active'), + queryFn: async () => { + const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( + `/api/folders?workspaceId=${workspaceId}&scope=active` + ) + return (folders ?? []).map(mapFolder) + }, + staleTime: FOLDER_LIST_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: workspaceFilesKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await prefetchInternalJson( + `/api/workspaces/${workspaceId}/files?scope=active` + ) + return data.success ? data.files : [] + }, + staleTime: 30 * 1000, + }), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index be3743be659..6243dc42035 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,8 +1,26 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' import { Knowledge } from './knowledge' export const metadata: Metadata = { title: 'Knowledge Base', } -export default Knowledge +export default async function KnowledgePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + await prefetchKnowledgeBases(queryClient, workspaceId) + + return ( + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts new file mode 100644 index 00000000000..0e8e5578840 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -0,0 +1,28 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' + +/** + * Prefetches the workspace's knowledge-bases list under the same query key the + * client `useKnowledgeBasesQuery` hook uses (scope `active`), so the list paints + * populated on first render. + * + * The list carries `Date` fields, so it goes through the `/api/knowledge` route + * and caches the serialized wire shape — see {@link prefetchInternalJson}. + */ +export async function prefetchKnowledgeBases( + queryClient: QueryClient, + workspaceId: string +): Promise { + await queryClient.prefetchQuery({ + queryKey: knowledgeKeys.list(workspaceId, 'active'), + queryFn: async () => { + const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( + `/api/knowledge?workspaceId=${workspaceId}&scope=active` + ) + return result.data + }, + staleTime: 60 * 1000, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts new file mode 100644 index 00000000000..e48f6064c17 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -0,0 +1,25 @@ +import { headers } from 'next/headers' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + +/** + * Server-side GET against an internal `/api` route, forwarding the incoming + * request's cookie so the route authenticates as the current user. + * + * List prefetches go through the route (rather than the data layer) when the + * payload carries `Date` fields: `NextResponse.json` serializes them to the + * string wire shape the client caches via `requestJson`, so the + * server-hydrated entry byte-matches the client-fetched one through + * dehydration. Calling the data layer directly would cache raw `Date` objects + * and drift from that wire shape. Mirrors the settings/subscription prefetch. + */ +export async function prefetchInternalJson(path: string): Promise { + const cookie = (await headers()).get('cookie') + // boundary-raw-fetch: server-side RSC prefetch forwarding the session cookie to an internal API route; requestJson is client-only and cannot run here + const response = await fetch(`${getInternalApiBaseUrl()}${path}`, { + headers: cookie ? { cookie } : {}, + }) + if (!response.ok) { + throw new Error(`Prefetch failed for ${path}: ${response.status}`) + } + return response.json() as Promise +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts new file mode 100644 index 00000000000..d031c0648ef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPrefetchInternalJson } = vi.hoisted(() => ({ + mockPrefetchInternalJson: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ + prefetchInternalJson: mockPrefetchInternalJson, +})) + +vi.mock('@/components/emcn', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' +import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' +import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' +import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' +import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { tableKeys } from '@/hooks/queries/tables' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +const WORKSPACE_ID = 'ws-123' + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +describe('workspace list prefetches', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('prefetchKnowledgeBases', () => { + it('primes the exact key useKnowledgeBasesQuery reads and unwraps data', async () => { + const bases = [{ id: 'kb-1' }] + mockPrefetchInternalJson.mockResolvedValue({ data: bases }) + const client = makeClient() + + await prefetchKnowledgeBases(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active` + ) + expect(client.getQueryData(knowledgeKeys.list(WORKSPACE_ID, 'active'))).toEqual(bases) + }) + }) + + describe('prefetchTables', () => { + it('primes the exact key useTablesList reads and unwraps data.tables', async () => { + const tables = [{ id: 't-1' }] + mockPrefetchInternalJson.mockResolvedValue({ data: { tables } }) + const client = makeClient() + + await prefetchTables(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/table?workspaceId=${WORKSPACE_ID}&scope=active` + ) + expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables) + }) + }) + + describe('prefetchFilesBrowser', () => { + it('primes both file + folder keys the client hooks read', async () => { + const files = [{ id: 'f-1' }] + const folders = [{ id: 'folder-1' }] + mockPrefetchInternalJson.mockImplementation(async (path: string) => + path.includes('/folders') ? { folders } : { success: true, files } + ) + const client = makeClient() + + await prefetchFilesBrowser(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/workspaces/${WORKSPACE_ID}/files?scope=active` + ) + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/workspaces/${WORKSPACE_ID}/files/folders?scope=active` + ) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) + expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( + folders + ) + }) + + it('caches an empty file list when the route reports failure', async () => { + mockPrefetchInternalJson.mockImplementation(async (path: string) => + path.includes('/folders') ? { folders: [] } : { success: false, files: [] } + ) + const client = makeClient() + + await prefetchFilesBrowser(client, WORKSPACE_ID) + + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual([]) + }) + }) + + describe('prefetchHomeLists', () => { + it('primes folder + file keys, mapping folder rows to the client shape', async () => { + const folderRow = { + id: 'folder-1', + name: 'Docs', + userId: 'u-1', + workspaceId: WORKSPACE_ID, + parentId: null, + color: null, + isExpanded: true, + locked: false, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + archivedAt: null, + } + const files = [{ id: 'f-1' }] + mockPrefetchInternalJson.mockImplementation(async (path: string) => + path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } + ) + const client = makeClient() + + await prefetchHomeLists(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/folders?workspaceId=${WORKSPACE_ID}&scope=active` + ) + const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{ + id: string + color: string + createdAt: Date + }> + expect(cachedFolders).toHaveLength(1) + expect(cachedFolders[0].color).toBe('#6B7280') + expect(cachedFolders[0].createdAt).toBeInstanceOf(Date) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) + }) + }) + + describe('graceful failure', () => { + it.each([ + [ + 'prefetchKnowledgeBases', + prefetchKnowledgeBases, + knowledgeKeys.list(WORKSPACE_ID, 'active'), + ], + ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], + ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], + [ + 'prefetchFilesBrowser', + prefetchFilesBrowser, + workspaceFilesKeys.list(WORKSPACE_ID, 'active'), + ], + ] as const)( + '%s does not throw when the fetcher rejects (page still renders, client refetches)', + async (_name, prefetch, queryKey) => { + mockPrefetchInternalJson.mockRejectedValue(new Error('500')) + const client = makeClient() + + await expect(prefetch(client, WORKSPACE_ID)).resolves.toBeUndefined() + expect(client.getQueryData(queryKey)).toBeUndefined() + } + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx index 15a016a25a2..0e9390a5d95 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx @@ -1,6 +1,9 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import TablesLoading from '@/app/workspace/[workspaceId]/tables/loading' +import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' import { Tables } from './tables' export const metadata: Metadata = { @@ -13,10 +16,17 @@ export const metadata: Metadata = { * fallback renders the real chrome so a suspend never shows a blank frame; the * route-level `loading.tsx` covers the navigation/chunk-load transition. */ -export default function TablesPage() { +export default async function TablesPage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + await prefetchTables(queryClient, workspaceId) + return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts new file mode 100644 index 00000000000..60d6a79a735 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -0,0 +1,26 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { TableDefinition } from '@/lib/table' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { tableKeys } from '@/hooks/queries/tables' + +/** + * Prefetches the workspace's tables list under the same query key the client + * `useTablesList` hook uses (scope `active`), so the list paints populated on + * first render. + * + * Table definitions carry `Date` fields, so the list goes through the + * `/api/table` route and caches the serialized wire shape — see + * {@link prefetchInternalJson}. + */ +export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise { + await queryClient.prefetchQuery({ + queryKey: tableKeys.list(workspaceId, 'active'), + queryFn: async () => { + const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( + `/api/table?workspaceId=${workspaceId}&scope=active` + ) + return response.data.tables + }, + staleTime: 30 * 1000, + }) +} diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index f0d8913cd05..34b695374fd 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -25,7 +25,15 @@ import type { WorkflowFolder } from '@/stores/folders/types' const logger = createLogger('FolderQueries') -function mapFolder(folder: FolderApi): WorkflowFolder { +export const FOLDER_LIST_STALE_TIME = 60 * 1000 + +/** + * Maps a wire folder row to the client `WorkflowFolder` shape (string dates → + * `Date`, color default). Exported so the server-side home prefetch produces + * the exact cached value `useFolders` stores, keeping the hydrated entry in + * sync with a client fetch. + */ +export function mapFolder(folder: FolderApi): WorkflowFolder { return { id: folder.id, name: folder.name,