Skip to content

Commit 2ec72b6

Browse files
authored
improvement(sidebar): prefetch workspace list server-side so the switcher never flashes loading (#5706)
1 parent f5a6c47 commit 2ec72b6

11 files changed

Lines changed: 285 additions & 158 deletions

File tree

apps/sim/app/_shell/providers/session-provider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { client } from '@/lib/auth/auth-client'
88
import {
99
type AppSession,
1010
extractSessionDataFromAuthClientResult,
11+
getActiveOrganizationId,
1112
} from '@/lib/auth/session-response'
1213
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
1314

@@ -71,7 +72,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
7172
queryClient.invalidateQueries({ queryKey: ['organizations'] })
7273
queryClient.invalidateQueries({ queryKey: ['subscription'] })
7374

74-
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
75+
const activeOrganizationId = getActiveOrganizationId(session)
7576
if (!session || activeOrganizationId) {
7677
return
7778
}

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

Lines changed: 16 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
3+
import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
66
import { and, eq, isNull } from 'drizzle-orm'
@@ -9,24 +9,20 @@ import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
99
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
1010
import { parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
12-
import type { PlanCategory } from '@/lib/billing/plan-helpers'
12+
import { getActiveOrganizationId } from '@/lib/auth/session-response'
1313
import { PlatformEvents } from '@/lib/core/telemetry'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { captureServerEvent } from '@/lib/posthog/server'
1616
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
1717
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
1818
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
19+
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
1920
import {
20-
CONTACT_OWNER_TO_UPGRADE_REASON,
21-
evaluateWorkspaceInvitePolicy,
22-
getInvitePlanCategoryForOrganization,
23-
getInvitePlanCategoryForUser,
2421
getWorkspaceCreationPolicy,
2522
getWorkspaceInvitePolicy,
26-
UPGRADE_TO_INVITE_REASON,
23+
resolveInviteFlags,
2724
WORKSPACE_MODE,
2825
} from '@/lib/workspaces/policy'
29-
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
3026

3127
const logger = createLogger('Workspaces')
3228

@@ -38,13 +34,6 @@ export const GET = withRouteHandler(async (request: Request) => {
3834
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3935
}
4036

41-
const activeOrganizationId =
42-
(session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null
43-
const creationPolicy = await getWorkspaceCreationPolicy({
44-
userId: session.user.id,
45-
activeOrganizationId,
46-
})
47-
4837
const scopeResult = listWorkspacesQuerySchema.safeParse(
4938
Object.fromEntries(new URL(request.url).searchParams.entries())
5039
)
@@ -56,20 +45,15 @@ export const GET = withRouteHandler(async (request: Request) => {
5645
}
5746
const { scope } = scopeResult.data
5847

59-
const settingsQuery = db
60-
.select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId })
61-
.from(settings)
62-
.where(eq(settings.userId, session.user.id))
63-
.limit(1)
64-
65-
const [userWorkspaces, userSettings] = await Promise.all([
66-
listAccessibleWorkspaceRowsForUser(session.user.id, scope),
67-
settingsQuery,
68-
])
69-
70-
const lastActiveWorkspaceId = userSettings[0]?.lastActiveWorkspaceId ?? null
48+
const activeOrganizationId = getActiveOrganizationId(session)
49+
const payload = await listWorkspacesForViewer({
50+
userId: session.user.id,
51+
activeOrganizationId,
52+
scope,
53+
})
54+
const { lastActiveWorkspaceId, creationPolicy } = payload
7155

72-
if (scope === 'active' && userWorkspaces.length === 0) {
56+
if (scope === 'active' && payload.workspaces.length === 0) {
7357
if (!creationPolicy.canCreate) {
7458
return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy })
7559
}
@@ -95,76 +79,10 @@ export const GET = withRouteHandler(async (request: Request) => {
9579
}
9680

9781
if (scope === 'active') {
98-
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
82+
await ensureWorkflowsHaveWorkspace(session.user.id, payload.workspaces[0].id)
9983
}
10084

101-
const nonOrgBilledUserIds = [
102-
...new Set(
103-
userWorkspaces
104-
.filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
105-
.map(({ workspace: ws }) => ws.billedAccountUserId)
106-
),
107-
]
108-
const orgIds = [
109-
...new Set(
110-
userWorkspaces
111-
.filter(
112-
({ workspace: ws }) =>
113-
ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId
114-
)
115-
.map(({ workspace: ws }) => ws.organizationId as string)
116-
),
117-
]
118-
const planCategoryByBilledUser = new Map<string, PlanCategory>()
119-
const planCategoryByOrg = new Map<string, PlanCategory>()
120-
await Promise.all([
121-
...nonOrgBilledUserIds.map(async (userId) => {
122-
planCategoryByBilledUser.set(userId, await getInvitePlanCategoryForUser(userId))
123-
}),
124-
...orgIds.map(async (orgId) => {
125-
planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId))
126-
}),
127-
])
128-
129-
const workspacesWithPermissions = userWorkspaces.map(
130-
({ workspace: workspaceDetails, permissionType }) => {
131-
const billedPlanCategory: PlanCategory =
132-
workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION
133-
? workspaceDetails.organizationId
134-
? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free')
135-
: 'free'
136-
: (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free')
137-
const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory })
138-
const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id
139-
140-
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
141-
const inviteDisabledReason = invitePolicy.allowed
142-
? null
143-
: callerIsBilledUser
144-
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
145-
: CONTACT_OWNER_TO_UPGRADE_REASON
146-
147-
return {
148-
...workspaceDetails,
149-
role:
150-
workspaceDetails.ownerId === session.user.id
151-
? 'owner'
152-
: permissionType === 'admin'
153-
? 'admin'
154-
: 'member',
155-
permissions: permissionType,
156-
inviteMembersEnabled: invitePolicy.allowed,
157-
inviteDisabledReason,
158-
inviteUpgradeRequired: canActOnUpgrade,
159-
}
160-
}
161-
)
162-
163-
return NextResponse.json({
164-
workspaces: workspacesWithPermissions,
165-
lastActiveWorkspaceId,
166-
creationPolicy,
167-
})
85+
return NextResponse.json(payload)
16886
})
16987

17088
// POST /api/workspaces - Create a new workspace
@@ -179,8 +97,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
17997
const parsed = await parseRequest(createWorkspaceContract, req, {})
18098
if (!parsed.success) return parsed.response
18199
const { name, color, skipDefaultWorkflow } = parsed.data.body
182-
const activeOrganizationId =
183-
(session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null
100+
const activeOrganizationId = getActiveOrganizationId(session)
184101
const creationPolicy = await getWorkspaceCreationPolicy({
185102
userId: session.user.id,
186103
activeOrganizationId,
@@ -380,13 +297,6 @@ async function createWorkspace({
380297
billedAccountUserId,
381298
ownerId: userId,
382299
})
383-
const callerIsBilledUser = billedAccountUserId === userId
384-
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
385-
const inviteDisabledReason = invitePolicy.allowed
386-
? null
387-
: callerIsBilledUser
388-
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
389-
: CONTACT_OWNER_TO_UPGRADE_REASON
390300

391301
return {
392302
id: workspaceId,
@@ -401,9 +311,7 @@ async function createWorkspace({
401311
updatedAt: now,
402312
role: 'owner',
403313
permissions: 'admin',
404-
inviteMembersEnabled: invitePolicy.allowed,
405-
inviteDisabledReason,
406-
inviteUpgradeRequired: canActOnUpgrade,
314+
...resolveInviteFlags(invitePolicy, billedAccountUserId === userId),
407315
}
408316
}
409317

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ describe('WorkspaceLayout host context', () => {
157157
expect.anything(),
158158
'workspace-b',
159159
'viewer-1',
160-
HOST_CONTEXT
160+
HOST_CONTEXT,
161+
'org-a'
161162
)
162163
expect(mockBrandingProvider).toHaveBeenCalledWith(
163164
expect.objectContaining({

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
33
import { cookies } from 'next/headers'
44
import { redirect } from 'next/navigation'
55
import { getSession } from '@/lib/auth'
6+
import { getActiveOrganizationId } from '@/lib/auth/session-response'
67
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
78
import {
89
ImpersonationBanner,
@@ -44,12 +45,19 @@ export default async function WorkspaceLayout({
4445
return <WorkspaceAccessDenied />
4546
}
4647

48+
const activeOrganizationId = getActiveOrganizationId(session)
4749
const [cookieStore, initialOrgSettings] = await Promise.all([
4850
cookies(),
4951
hostContext.hostOrganizationId
5052
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
5153
: Promise.resolve(null),
52-
prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id, hostContext),
54+
prefetchWorkspaceSidebar(
55+
queryClient,
56+
workspaceId,
57+
session.user.id,
58+
hostContext,
59+
activeOrganizationId
60+
),
5361
])
5462
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
5563

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

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { QueryClient } from '@tanstack/react-query'
2-
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
2+
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
33
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
44
import { listFoldersForWorkspace } from '@/lib/folders/queries'
55
import { listWorkflowsForUser } from '@/lib/workflows/queries'
66
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
7+
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
78
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
89
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
910
import {
@@ -14,6 +15,10 @@ import {
1415
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
1516
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
1617
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
18+
import {
19+
normalizeWorkspacesResponse,
20+
WORKSPACE_LIST_STALE_TIME,
21+
} from '@/hooks/queries/utils/workspace-list-query'
1722
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
1823
import {
1924
WORKSPACE_HOST_CONTEXT_STALE_TIME,
@@ -37,22 +42,27 @@ export function prefetchWorkspaceHostContext(
3742
}
3843

3944
/**
40-
* Prefetches the sidebar's workflow, chat, folder, and workspace-permissions lists for
41-
* a workspace and stores them under the same query keys + mappers the client hooks use,
42-
* so the persistent sidebar paints populated on the first server render instead of
43-
* flashing skeletons on a cold load (e.g. after the browser discards an idle tab). Calls
44-
* the data layer directly — the same functions the API routes use — with no internal
45-
* HTTP hop.
45+
* Prefetches the sidebar's workflow, chat, folder, workspace-permissions, and
46+
* workspace lists for a workspace and stores them under the same query keys +
47+
* mappers the client hooks use, so the persistent sidebar (including the
48+
* workspace switcher header) paints populated on the first server render
49+
* instead of flashing skeletons on a cold load (e.g. after the browser
50+
* discards an idle tab). Calls the data layer directly — the same functions
51+
* the API routes use — with no internal HTTP hop.
4652
*
4753
* The host context is the authorization proof for this server-render pass, so
4854
* permission prefetch can reuse its effective permission without repeating
49-
* workspace and membership reads.
55+
* workspace and membership reads. It also proves the viewer has at least one
56+
* accessible workspace, which is why the workspace-list prefetch can safely
57+
* skip the route's empty-list default-workspace creation path — and the
58+
* route's orphaned-workflow repair, which still runs on client refetches.
5059
*/
5160
export async function prefetchWorkspaceSidebar(
5261
queryClient: QueryClient,
5362
workspaceId: string,
5463
userId: string,
55-
hostContext: WorkspaceHostContext
64+
hostContext: WorkspaceHostContext,
65+
activeOrganizationId: string | null
5666
): Promise<void> {
5767
if (hostContext.workspace.id !== workspaceId) return
5868
await Promise.all([
@@ -80,6 +90,27 @@ export async function prefetchWorkspaceSidebar(
8090
},
8191
staleTime: FOLDER_LIST_STALE_TIME,
8292
}),
93+
queryClient.prefetchQuery({
94+
queryKey: workspaceKeys.list('active'),
95+
queryFn: async () => {
96+
const payload = await listWorkspacesForViewer({
97+
userId,
98+
activeOrganizationId,
99+
scope: 'active',
100+
})
101+
// An empty list means GET /api/workspaces' default-workspace creation
102+
// path must run — throw so prefetchQuery caches nothing and the client
103+
// fetch reaches the route.
104+
if (payload.workspaces.length === 0) {
105+
throw new Error('Empty workspace list requires the route creation path')
106+
}
107+
// Parsing through the route contract's response schema strips the same
108+
// server-only fields `requestJson` strips on the client, guaranteeing the
109+
// cached shape is identical to a client fetch.
110+
return normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload))
111+
},
112+
staleTime: WORKSPACE_LIST_STALE_TIME,
113+
}),
83114
queryClient.prefetchQuery({
84115
queryKey: workspaceKeys.permissions(workspaceId),
85116
queryFn: () =>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
3434

3535
const logger = createLogger('WorkspaceHeader')
3636

37+
/**
38+
* Derives the single-letter avatar initial for a workspace, ignoring the word
39+
* "workspace" in the name (e.g. "Acme Workspace" → "A").
40+
*/
41+
function getWorkspaceInitial(name: string | undefined): string {
42+
const stripped = (name ?? '').replace(/workspace/gi, '').trim()
43+
return (stripped[0] || name?.[0] || 'W').toUpperCase()
44+
}
45+
3746
interface DisabledReasonTooltipProps {
3847
reason: string | null
3948
children: ReactElement
@@ -182,11 +191,7 @@ function WorkspaceHeaderImpl({
182191
}
183192
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])
184193

185-
const workspaceInitial = (() => {
186-
const name = activeWorkspace?.name || ''
187-
const stripped = name.replace(/workspace/gi, '').trim()
188-
return (stripped[0] || name[0] || 'W').toUpperCase()
189-
})()
194+
const workspaceInitial = getWorkspaceInitial(activeWorkspace?.name)
190195

191196
/**
192197
* Opens the context menu for a workspace at the specified position
@@ -418,8 +423,7 @@ function WorkspaceHeaderImpl({
418423
<>
419424
<div className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'>
420425
{workspaces.map((workspace) => {
421-
const stripped = workspace.name.replace(/workspace/gi, '').trim()
422-
const initial = (stripped[0] || workspace.name[0] || 'W').toUpperCase()
426+
const initial = getWorkspaceInitial(workspace.name)
423427
const isActive = workspace.id === workspaceId
424428
const isMenuOpen = menuOpenWorkspaceId === workspace.id
425429

0 commit comments

Comments
 (0)