Skip to content

Commit 4c7e63c

Browse files
committed
improvement(settings): SSR prefetch, code splitting, dedicated skeletons
1 parent 8fc75a6 commit 4c7e63c

File tree

31 files changed

+667
-289
lines changed

31 files changed

+667
-289
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query'
2+
3+
function makeQueryClient() {
4+
return new QueryClient({
5+
defaultOptions: {
6+
queries: {
7+
staleTime: 30 * 1000,
8+
gcTime: 5 * 60 * 1000,
9+
refetchOnWindowFocus: false,
10+
retry: 1,
11+
retryOnMount: false,
12+
},
13+
mutations: {
14+
retry: 1,
15+
},
16+
dehydrate: {
17+
shouldDehydrateQuery: (query) =>
18+
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
19+
},
20+
},
21+
})
22+
}
23+
24+
let browserQueryClient: QueryClient | undefined
25+
26+
/**
27+
* Returns a QueryClient instance. On the server, creates a new instance per request.
28+
* On the client, reuses a singleton instance.
29+
*/
30+
export function getQueryClient() {
31+
if (isServer) {
32+
return makeQueryClient()
33+
}
34+
if (!browserQueryClient) {
35+
browserQueryClient = makeQueryClient()
36+
}
37+
return browserQueryClient
38+
}

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

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,10 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { QueryClientProvider } from '@tanstack/react-query'
5+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
56

6-
/**
7-
* Singleton QueryClient instance for client-side use.
8-
* Can be imported directly for cache operations outside React components.
9-
*/
10-
function makeQueryClient() {
11-
return new QueryClient({
12-
defaultOptions: {
13-
queries: {
14-
staleTime: 30 * 1000,
15-
gcTime: 5 * 60 * 1000,
16-
refetchOnWindowFocus: false,
17-
retry: 1,
18-
retryOnMount: false,
19-
},
20-
mutations: {
21-
retry: 1,
22-
},
23-
},
24-
})
25-
}
26-
27-
let browserQueryClient: QueryClient | undefined
28-
29-
export function getQueryClient() {
30-
if (typeof window === 'undefined') {
31-
return makeQueryClient()
32-
}
33-
if (!browserQueryClient) {
34-
browserQueryClient = makeQueryClient()
35-
}
36-
return browserQueryClient
37-
}
7+
export { getQueryClient } from '@/app/_shell/providers/get-query-client'
388

399
export function QueryProvider({ children }: { children: ReactNode }) {
4010
const queryClient = getQueryClient()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use client'
2+
3+
import { ErrorState } from '@/app/workspace/[workspaceId]/components'
4+
5+
export default function SettingsSectionError({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string }
10+
reset: () => void
11+
}) {
12+
return (
13+
<div className='flex h-full items-center justify-center'>
14+
<ErrorState
15+
error={error}
16+
reset={reset}
17+
title='Something went wrong'
18+
description='An unexpected error occurred. Please try again or refresh the page.'
19+
loggerName='SettingsSectionError'
20+
/>
21+
</div>
22+
)
23+
}

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
12
import type { Metadata } from 'next'
3+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
24
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
5+
import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch'
36
import { SettingsPage } from './settings'
47

58
const SECTION_TITLES: Record<string, string> = {
@@ -36,5 +39,14 @@ export default async function SettingsSectionPage({
3639
params: Promise<{ workspaceId: string; section: string }>
3740
}) {
3841
const { section } = await params
39-
return <SettingsPage section={section as SettingsSection} />
42+
const queryClient = getQueryClient()
43+
44+
void prefetchGeneralSettings(queryClient)
45+
void prefetchUserProfile(queryClient)
46+
47+
return (
48+
<HydrationBoundary state={dehydrate(queryClient)}>
49+
<SettingsPage section={section as SettingsSection} />
50+
</HydrationBoundary>
51+
)
4052
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { QueryClient } from '@tanstack/react-query'
2+
import { headers } from 'next/headers'
3+
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
4+
import { generalSettingsKeys } from '@/hooks/queries/general-settings'
5+
import { userProfileKeys } from '@/hooks/queries/user-profile'
6+
7+
/**
8+
* Forwards incoming request cookies so server-side API fetches authenticate correctly.
9+
*/
10+
async function getForwardedHeaders(): Promise<Record<string, string>> {
11+
const h = await headers()
12+
const cookie = h.get('cookie')
13+
return cookie ? { cookie } : {}
14+
}
15+
16+
/**
17+
* Prefetch general settings server-side via internal API fetch.
18+
* Uses the same query keys as the client `useGeneralSettings` hook
19+
* so data is shared via HydrationBoundary.
20+
*/
21+
export function prefetchGeneralSettings(queryClient: QueryClient) {
22+
return queryClient.prefetchQuery({
23+
queryKey: generalSettingsKeys.settings(),
24+
queryFn: async () => {
25+
const fwdHeaders = await getForwardedHeaders()
26+
const baseUrl = getInternalApiBaseUrl()
27+
const response = await fetch(`${baseUrl}/api/users/me/settings`, {
28+
headers: fwdHeaders,
29+
})
30+
if (!response.ok) throw new Error(`Settings prefetch failed: ${response.status}`)
31+
const { data } = await response.json()
32+
return {
33+
autoConnect: data.autoConnect ?? true,
34+
showTrainingControls: data.showTrainingControls ?? false,
35+
superUserModeEnabled: data.superUserModeEnabled ?? true,
36+
theme: data.theme || 'system',
37+
telemetryEnabled: data.telemetryEnabled ?? true,
38+
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
39+
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
40+
snapToGridSize: data.snapToGridSize ?? 0,
41+
showActionBar: data.showActionBar ?? true,
42+
}
43+
},
44+
staleTime: 60 * 60 * 1000,
45+
})
46+
}
47+
48+
/**
49+
* Prefetch user profile server-side via internal API fetch.
50+
* Uses the same query keys as the client `useUserProfile` hook
51+
* so data is shared via HydrationBoundary.
52+
*/
53+
export function prefetchUserProfile(queryClient: QueryClient) {
54+
return queryClient.prefetchQuery({
55+
queryKey: userProfileKeys.profile(),
56+
queryFn: async () => {
57+
const fwdHeaders = await getForwardedHeaders()
58+
const baseUrl = getInternalApiBaseUrl()
59+
const response = await fetch(`${baseUrl}/api/users/me/profile`, {
60+
headers: fwdHeaders,
61+
})
62+
if (!response.ok) throw new Error(`Profile prefetch failed: ${response.status}`)
63+
const { user } = await response.json()
64+
return {
65+
id: user.id,
66+
name: user.name || '',
67+
email: user.email || '',
68+
image: user.image || null,
69+
createdAt: user.createdAt,
70+
updatedAt: user.updatedAt,
71+
}
72+
},
73+
staleTime: 5 * 60 * 1000,
74+
})
75+
}

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,134 @@
11
'use client'
22

3+
import dynamic from 'next/dynamic'
34
import { useSearchParams } from 'next/navigation'
4-
import {
5-
ApiKeys,
6-
BYOK,
7-
Copilot,
8-
CredentialSets,
9-
Credentials,
10-
CustomTools,
11-
Debug,
12-
General,
13-
MCP,
14-
Skills,
15-
Subscription,
16-
TeamManagement,
17-
TemplateProfile,
18-
WorkflowMcpServers,
19-
} from '@/app/workspace/[workspaceId]/settings/components'
5+
import { Skeleton } from '@/components/emcn'
6+
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
7+
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
8+
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
9+
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
10+
import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
11+
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
12+
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
13+
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
14+
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
15+
import { SkillsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
16+
import { WorkflowMcpServersSkeleton } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton'
2017
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
2118
import {
2219
allNavigationItems,
2320
isBillingEnabled,
2421
} from '@/app/workspace/[workspaceId]/settings/navigation'
25-
import { AccessControl } from '@/ee/access-control/components/access-control'
26-
import { SSO } from '@/ee/sso/components/sso-settings'
22+
23+
/**
24+
* Generic skeleton fallback for sections without a dedicated skeleton.
25+
*/
26+
function SettingsSectionSkeleton() {
27+
return (
28+
<div className='flex flex-col gap-[16px]'>
29+
<Skeleton className='h-[20px] w-[200px] rounded-[4px]' />
30+
<Skeleton className='h-[40px] w-full rounded-[8px]' />
31+
<Skeleton className='h-[40px] w-full rounded-[8px]' />
32+
<Skeleton className='h-[40px] w-full rounded-[8px]' />
33+
</div>
34+
)
35+
}
36+
37+
const General = dynamic(
38+
() =>
39+
import('@/app/workspace/[workspaceId]/settings/components/general/general').then(
40+
(m) => m.General
41+
),
42+
{ loading: () => <GeneralSkeleton /> }
43+
)
44+
const Credentials = dynamic(
45+
() =>
46+
import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials').then(
47+
(m) => m.Credentials
48+
),
49+
{ loading: () => <CredentialsSkeleton /> }
50+
)
51+
const TemplateProfile = dynamic(
52+
() =>
53+
import(
54+
'@/app/workspace/[workspaceId]/settings/components/template-profile/template-profile'
55+
).then((m) => m.TemplateProfile),
56+
{ loading: () => <SettingsSectionSkeleton /> }
57+
)
58+
const CredentialSets = dynamic(
59+
() =>
60+
import(
61+
'@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets'
62+
).then((m) => m.CredentialSets),
63+
{ loading: () => <CredentialSetsSkeleton /> }
64+
)
65+
const ApiKeys = dynamic(
66+
() =>
67+
import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
68+
(m) => m.ApiKeys
69+
),
70+
{ loading: () => <ApiKeysSkeleton /> }
71+
)
72+
const Subscription = dynamic(
73+
() =>
74+
import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription').then(
75+
(m) => m.Subscription
76+
),
77+
{ loading: () => <SettingsSectionSkeleton /> }
78+
)
79+
const TeamManagement = dynamic(
80+
() =>
81+
import(
82+
'@/app/workspace/[workspaceId]/settings/components/team-management/team-management'
83+
).then((m) => m.TeamManagement),
84+
{ loading: () => <SettingsSectionSkeleton /> }
85+
)
86+
const BYOK = dynamic(
87+
() => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK),
88+
{ loading: () => <BYOKSkeleton /> }
89+
)
90+
const Copilot = dynamic(
91+
() =>
92+
import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then(
93+
(m) => m.Copilot
94+
),
95+
{ loading: () => <CopilotSkeleton /> }
96+
)
97+
const MCP = dynamic(
98+
() => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP),
99+
{ loading: () => <McpSkeleton /> }
100+
)
101+
const CustomTools = dynamic(
102+
() =>
103+
import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then(
104+
(m) => m.CustomTools
105+
),
106+
{ loading: () => <CustomToolsSkeleton /> }
107+
)
108+
const Skills = dynamic(
109+
() =>
110+
import('@/app/workspace/[workspaceId]/settings/components/skills/skills').then((m) => m.Skills),
111+
{ loading: () => <SkillsSkeleton /> }
112+
)
113+
const WorkflowMcpServers = dynamic(
114+
() =>
115+
import(
116+
'@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers'
117+
).then((m) => m.WorkflowMcpServers),
118+
{ loading: () => <WorkflowMcpServersSkeleton /> }
119+
)
120+
const Debug = dynamic(
121+
() =>
122+
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
123+
{ loading: () => <DebugSkeleton /> }
124+
)
125+
const AccessControl = dynamic(
126+
() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl),
127+
{ loading: () => <SettingsSectionSkeleton /> }
128+
)
129+
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
130+
loading: () => <SettingsSectionSkeleton />,
131+
})
27132

28133
interface SettingsPageProps {
29134
section: SettingsSection
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Skeleton } from '@/components/ui'
2+
3+
/**
4+
* Skeleton component for API key list items.
5+
*/
6+
export function ApiKeySkeleton() {
7+
return (
8+
<div className='flex items-center justify-between gap-[12px]'>
9+
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
10+
<div className='flex items-center gap-[6px]'>
11+
<Skeleton className='h-5 w-[80px]' />
12+
<Skeleton className='h-5 w-[140px]' />
13+
</div>
14+
<Skeleton className='h-5 w-[100px]' />
15+
</div>
16+
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
17+
</div>
18+
)
19+
}
20+
21+
/**
22+
* Skeleton for the API Keys section shown during dynamic import loading.
23+
*/
24+
export function ApiKeysSkeleton() {
25+
return (
26+
<div className='flex flex-col gap-[12px]'>
27+
<ApiKeySkeleton />
28+
<ApiKeySkeleton />
29+
</div>
30+
)
31+
}

0 commit comments

Comments
 (0)