Skip to content

Commit 8353145

Browse files
authored
fix(sidebar): prefetch chats + workflows so cold loads don't flash skeletons (#5104)
* fix(sidebar): prefetch chats + workflows so cold loads don't flash skeletons On a cold load (e.g. when the browser discards an idle tab and reloads), the persistent sidebar started with an empty React Query cache and client-fetched its chat + workflow lists, flashing loading skeletons. Prefetch both lists server-side in the workspace layout and hydrate them via HydrationBoundary, under the same query keys and mappers the client hooks use, so the sidebar paints populated on the first render. The prefetch runs concurrently with the existing org-settings fetch and never throws, so it adds no blocking work in the common case and falls back to client fetching on error. * refactor(prefetch): call data layer directly instead of internal HTTP self-fetch The sidebar and settings prefetches fetched their data by making internal HTTP requests to our own API routes. Replace those self-fetches with direct calls to shared server-side data functions, so each route handler and its prefetch read from one source with no extra network hop, serialization, or re-auth. - Extract listWorkflowsForUser (lib/workflows/queries) and listMothershipChats (lib/copilot/chat) from their routes; both routes and the sidebar prefetch now call them. - Extract getUserSettings/getUserProfile (lib/users/queries) shared by the settings/profile routes and their prefetches. - Subscription prefetch calls the existing getSimplifiedBillingSummary + getEffectiveBillingStatus directly. - Sidebar prefetch checks workspace access once via checkWorkspaceAccess and skips silently when denied. * refactor(prefetch): share mothership chat list staleTime constant Export MOTHERSHIP_CHAT_LIST_STALE_TIME from the chats hook and use it in both useMothershipChats and the sidebar prefetch, mirroring WORKFLOW_LIST_STALE_TIME so the prefetch and client hook can't drift. * fix(prefetch): keep subscription prefetch on the wire shape via internal billing API The billing summary returns Date fields (and an untyped metadata blob) that the JSON API serializes to strings. Calling the data layer directly would cache Date objects (App Router preserves them through RSC serialization), mismatching the string wire shape the client useSubscriptionData hook caches. Route the subscription prefetch through the internal billing API so server-hydrated and client-fetched data share the exact same shape. The date-free general-settings and profile prefetches keep calling the data layer directly.
1 parent a82b44d commit 8353145

11 files changed

Lines changed: 362 additions & 212 deletions

File tree

apps/sim/app/api/mothership/chats/route.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { db } from '@sim/db'
22
import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, desc, eq } from 'drizzle-orm'
54
import { type NextRequest, NextResponse } from 'next/server'
65
import {
76
createMothershipChatContract,
87
listMothershipChatsContract,
98
} from '@/lib/api/contracts/mothership-chats'
109
import { parseRequest } from '@/lib/api/server'
11-
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
10+
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
1211
import { chatPubSub } from '@/lib/copilot/chat-status'
1312
import {
1413
authenticateCopilotRequestSessionOnly,
@@ -42,35 +41,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4241

4342
await assertActiveWorkspaceAccess(workspaceId, userId)
4443

45-
const chats = await db
46-
.select({
47-
id: copilotChats.id,
48-
title: copilotChats.title,
49-
updatedAt: copilotChats.updatedAt,
50-
activeStreamId: copilotChats.conversationId,
51-
lastSeenAt: copilotChats.lastSeenAt,
52-
pinned: copilotChats.pinned,
53-
})
54-
.from(copilotChats)
55-
.where(
56-
and(
57-
eq(copilotChats.userId, userId),
58-
eq(copilotChats.workspaceId, workspaceId),
59-
eq(copilotChats.type, 'mothership')
60-
)
61-
)
62-
.orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt))
63-
64-
const streamMarkers = await reconcileChatStreamMarkers(
65-
chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })),
66-
{ repairVerifiedStaleMarkers: true }
67-
)
68-
const reconciled = chats.map((c) => {
69-
const activeStreamId = streamMarkers.get(c.id)?.streamId ?? null
70-
return activeStreamId === c.activeStreamId ? c : { ...c, activeStreamId }
71-
})
44+
const data = await listMothershipChats(userId, workspaceId)
7245

73-
return NextResponse.json({ success: true, data: reconciled })
46+
return NextResponse.json({ success: true, data })
7447
} catch (error) {
7548
if (isWorkspaceAccessDeniedError(error)) {
7649
return createForbiddenResponse('Workspace access denied')

apps/sim/app/api/users/me/profile/route.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { getUserProfile } from '@/lib/users/queries'
1112

1213
const logger = createLogger('UpdateUserProfileAPI')
1314

@@ -84,17 +85,7 @@ export const GET = withRouteHandler(async () => {
8485

8586
const userId = session.user.id
8687

87-
const [userRecord] = await db
88-
.select({
89-
id: user.id,
90-
name: user.name,
91-
email: user.email,
92-
image: user.image,
93-
emailVerified: user.emailVerified,
94-
})
95-
.from(user)
96-
.where(eq(user.id, userId))
97-
.limit(1)
88+
const userRecord = await getUserProfile(userId)
9889

9990
if (!userRecord) {
10091
return NextResponse.json({ error: 'User not found' }, { status: 404 })

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 4 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,93 +2,26 @@ import { db } from '@sim/db'
22
import { settings } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateShortId } from '@sim/utils/id'
5-
import { eq } from 'drizzle-orm'
65
import { type NextRequest, NextResponse } from 'next/server'
76
import { updateUserSettingsContract } from '@/lib/api/contracts'
87
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
98
import { getSession } from '@/lib/auth'
109
import { generateRequestId } from '@/lib/core/utils/request'
1110
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { defaultUserSettings, getUserSettings } from '@/lib/users/queries'
1212

1313
const logger = createLogger('UserSettingsAPI')
1414

15-
const defaultSettings = {
16-
theme: 'system',
17-
autoConnect: true,
18-
telemetryEnabled: true,
19-
emailPreferences: {},
20-
billingUsageNotificationsEnabled: true,
21-
showTrainingControls: false,
22-
superUserModeEnabled: false,
23-
mothershipEnvironment: 'default',
24-
errorNotificationsEnabled: true,
25-
snapToGridSize: 0,
26-
showActionBar: true,
27-
timezone: null,
28-
lastActiveWorkspaceId: null,
29-
}
30-
3115
export const GET = withRouteHandler(async () => {
3216
const requestId = generateRequestId()
3317

3418
try {
3519
const session = await getSession()
36-
37-
if (!session?.user?.id) {
38-
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
39-
return NextResponse.json({ data: defaultSettings }, { status: 200 })
40-
}
41-
42-
const userId = session.user.id
43-
const result = await db
44-
.select({
45-
theme: settings.theme,
46-
autoConnect: settings.autoConnect,
47-
telemetryEnabled: settings.telemetryEnabled,
48-
emailPreferences: settings.emailPreferences,
49-
billingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
50-
showTrainingControls: settings.showTrainingControls,
51-
superUserModeEnabled: settings.superUserModeEnabled,
52-
mothershipEnvironment: settings.mothershipEnvironment,
53-
errorNotificationsEnabled: settings.errorNotificationsEnabled,
54-
snapToGridSize: settings.snapToGridSize,
55-
showActionBar: settings.showActionBar,
56-
timezone: settings.timezone,
57-
lastActiveWorkspaceId: settings.lastActiveWorkspaceId,
58-
})
59-
.from(settings)
60-
.where(eq(settings.userId, userId))
61-
.limit(1)
62-
63-
if (!result.length) {
64-
return NextResponse.json({ data: defaultSettings }, { status: 200 })
65-
}
66-
67-
const userSettings = result[0]
68-
69-
return NextResponse.json(
70-
{
71-
data: {
72-
theme: userSettings.theme,
73-
autoConnect: userSettings.autoConnect,
74-
telemetryEnabled: userSettings.telemetryEnabled,
75-
emailPreferences: userSettings.emailPreferences ?? {},
76-
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
77-
showTrainingControls: userSettings.showTrainingControls ?? false,
78-
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
79-
mothershipEnvironment: userSettings.mothershipEnvironment ?? 'default',
80-
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
81-
snapToGridSize: userSettings.snapToGridSize ?? 0,
82-
showActionBar: userSettings.showActionBar ?? true,
83-
timezone: userSettings.timezone ?? null,
84-
lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null,
85-
},
86-
},
87-
{ status: 200 }
88-
)
20+
const data = await getUserSettings(session?.user?.id ?? null)
21+
return NextResponse.json({ data }, { status: 200 })
8922
} catch (error: any) {
9023
logger.error(`[${requestId}] Settings fetch error`, error)
91-
return NextResponse.json({ data: defaultSettings }, { status: 200 })
24+
return NextResponse.json({ data: defaultUserSettings }, { status: 200 })
9225
}
9326
})
9427

apps/sim/app/api/workflows/route.ts

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { db } from '@sim/db'
2-
import { permissions, workflow } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'
52
import { type NextRequest, NextResponse } from 'next/server'
63
import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows'
74
import { parseRequest } from '@/lib/api/server'
@@ -10,12 +7,12 @@ import { generateRequestId } from '@/lib/core/utils/request'
107
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
118
import { captureServerEvent } from '@/lib/posthog/server'
129
import { performCreateWorkflow } from '@/lib/workflows/orchestration'
10+
import { listWorkflowsForUser } from '@/lib/workflows/queries'
1311
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
1412
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
1513

1614
const logger = createLogger('WorkflowAPI')
1715

18-
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
1916
export const GET = withRouteHandler(async (request: NextRequest) => {
2017
const requestId = generateRequestId()
2118
const startTime = Date.now()
@@ -63,65 +60,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6360
}
6461
}
6562

66-
let workflows
67-
68-
/**
69-
* Project only the columns declared in `workflowListItemSchema` so the
70-
* wire response matches the contract shape exactly. The full row is
71-
* larger (`state`, `variables`, `apiKey`, `runCount`, etc.) and would
72-
* be dropped client-side by Zod parse anyway — narrowing here saves
73-
* bytes over the wire. Keep this list aligned with the contract.
74-
*/
75-
const listColumns = {
76-
id: workflow.id,
77-
name: workflow.name,
78-
description: workflow.description,
79-
workspaceId: workflow.workspaceId,
80-
folderId: workflow.folderId,
81-
sortOrder: workflow.sortOrder,
82-
createdAt: workflow.createdAt,
83-
updatedAt: workflow.updatedAt,
84-
archivedAt: workflow.archivedAt,
85-
locked: workflow.locked,
86-
} as const
87-
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
88-
89-
if (workspaceId) {
90-
workflows = await db
91-
.select(listColumns)
92-
.from(workflow)
93-
.where(
94-
scope === 'all'
95-
? eq(workflow.workspaceId, workspaceId)
96-
: scope === 'archived'
97-
? and(eq(workflow.workspaceId, workspaceId), sql`${workflow.archivedAt} IS NOT NULL`)
98-
: and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))
99-
)
100-
.orderBy(...orderByClause)
101-
} else {
102-
const workspacePermissionRows = await db
103-
.select({ workspaceId: permissions.entityId })
104-
.from(permissions)
105-
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
106-
const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId)
107-
if (workspaceIds.length === 0) {
108-
return NextResponse.json({ data: [] }, { status: 200 })
109-
}
110-
workflows = await db
111-
.select(listColumns)
112-
.from(workflow)
113-
.where(
114-
scope === 'all'
115-
? inArray(workflow.workspaceId, workspaceIds)
116-
: scope === 'archived'
117-
? and(
118-
inArray(workflow.workspaceId, workspaceIds),
119-
sql`${workflow.archivedAt} IS NOT NULL`
120-
)
121-
: and(inArray(workflow.workspaceId, workspaceIds), isNull(workflow.archivedAt))
122-
)
123-
.orderBy(...orderByClause)
124-
}
63+
const workflows = await listWorkflowsForUser({ userId, workspaceId, scope })
12564

12665
return NextResponse.json({ data: workflows }, { status: 200 })
12766
} catch (error: any) {

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
12
import { redirect } from 'next/navigation'
23
import { ToastProvider } from '@/components/emcn'
34
import { getSession } from '@/lib/auth'
5+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
46
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
57
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
8+
import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
69
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
710
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
811
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -11,15 +14,28 @@ import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/work
1114
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
1215
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
1316

14-
export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) {
17+
export default async function WorkspaceLayout({
18+
children,
19+
params,
20+
}: {
21+
children: React.ReactNode
22+
params: Promise<{ workspaceId: string }>
23+
}) {
1524
const session = await getSession()
1625
if (!session?.user) {
1726
redirect('/login')
1827
}
28+
29+
const { workspaceId } = await params
30+
const queryClient = getQueryClient()
31+
const sidebarPrefetch = prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id)
32+
1933
// The organization plugin is conditionally spread so TS can't infer activeOrganizationId on the base session type.
2034
const orgId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId
2135
const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null
2236

37+
await sidebarPrefetch
38+
2339
return (
2440
<BrandingProvider initialOrgSettings={initialOrgSettings}>
2541
<ToastProvider>
@@ -30,7 +46,9 @@ export default async function WorkspaceLayout({ children }: { children: React.Re
3046
<ImpersonationBanner />
3147
<WorkspacePermissionsProvider>
3248
<WorkspaceScopeSync />
33-
<WorkspaceChrome>{children}</WorkspaceChrome>
49+
<HydrationBoundary state={dehydrate(queryClient)}>
50+
<WorkspaceChrome>{children}</WorkspaceChrome>
51+
</HydrationBoundary>
3452
</WorkspacePermissionsProvider>
3553
</div>
3654
</GlobalCommandsProvider>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { QueryClient } from '@tanstack/react-query'
2+
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
3+
import { listWorkflowsForUser } from '@/lib/workflows/queries'
4+
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
5+
import {
6+
MOTHERSHIP_CHAT_LIST_STALE_TIME,
7+
mapChat,
8+
mothershipChatKeys,
9+
} from '@/hooks/queries/mothership-chats'
10+
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
11+
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
12+
13+
/** Resolves whether the user may access the workspace, swallowing errors to a `false`. */
14+
async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise<boolean> {
15+
try {
16+
const access = await checkWorkspaceAccess(workspaceId, userId)
17+
return access.exists && access.hasAccess
18+
} catch {
19+
return false
20+
}
21+
}
22+
23+
/**
24+
* Prefetches the sidebar's workflow + chat lists for a workspace and stores them
25+
* under the same query keys + mappers the client hooks use, so the persistent
26+
* sidebar paints populated on the first server render instead of flashing skeletons
27+
* on a cold load (e.g. after the browser discards an idle tab). Calls the data layer
28+
* directly — the same functions the API routes use — with no internal HTTP hop.
29+
*
30+
* Skips silently when the user can't access the workspace, leaving the client to
31+
* fetch and surface the real error instead of caching an empty list.
32+
*/
33+
export async function prefetchWorkspaceSidebar(
34+
queryClient: QueryClient,
35+
workspaceId: string,
36+
userId: string
37+
): Promise<void> {
38+
if (!(await userCanAccessWorkspace(workspaceId, userId))) return
39+
await Promise.all([
40+
queryClient.prefetchQuery({
41+
queryKey: workflowKeys.list(workspaceId, 'active'),
42+
queryFn: async () => {
43+
const rows = await listWorkflowsForUser({ userId, workspaceId, scope: 'active' })
44+
return rows.map(mapWorkflow)
45+
},
46+
staleTime: WORKFLOW_LIST_STALE_TIME,
47+
}),
48+
queryClient.prefetchQuery({
49+
queryKey: mothershipChatKeys.list(workspaceId),
50+
queryFn: async () => {
51+
const data = await listMothershipChats(userId, workspaceId)
52+
return data.map(mapChat)
53+
},
54+
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
55+
}),
56+
])
57+
}

0 commit comments

Comments
 (0)