Skip to content

Commit c48e338

Browse files
committed
improvement(settings): reduce navigation and section load latency
1 parent 6a5e250 commit c48e338

36 files changed

Lines changed: 1164 additions & 395 deletions

File tree

.agents/skills/react-query-best-practices/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ Read these before analyzing:
3535
- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number
3636
- `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys
3737
- Use `enabled` to prevent queries from running without required params
38+
- Compose caller-controlled `enabled` options with required-param guards (`Boolean(id) && (options?.enabled ?? true)`). Never spread options after an internal guard, because `{ enabled: true }` can silently re-enable an invalid request.
39+
- A disabled query can still report `isPending: true`. Aggregate loading state only for queries that are applicable/enabled, or an optional query can hold the whole surface in a permanent loading state.
40+
- Deferred authorization or policy queries must fail closed. Do not give pending/error data the same fallback as a successfully loaded unrestricted policy; disable guarded actions until the policy query succeeds.
3841

3942
### Mutations
4043
- Use `onSettled` (not `onSuccess`) for cache reconciliation — it fires on both success and error
@@ -46,7 +49,7 @@ Read these before analyzing:
4649
- Never copy query data into useState. Use query data directly in components.
4750
- Never copy query data into Zustand stores (exception: mutation callbacks that coordinate cross-store state like temp ID replacement)
4851
- The query cache is not a local state manager — `setQueryData` is for optimistic updates only
49-
- Forms are the one deliberate exception: copy server data into local form state with `staleTime: Infinity`
52+
- Forms are the one deliberate exception: once query data exists, initialize a keyed form subtree from it with lazy state initializers. Do not synchronize query data into draft state with an Effect; key the form by resource identity so switching resources resets every draft/modal/upload field together. Keep independent queries in the outer wrapper so they still start in parallel.
5053

5154
## Steps
5255

.agents/skills/you-might-not-need-an-effect/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ Steps:
1616
1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines
1717
2. Analyze the specified scope for useEffect anti-patterns
1818
3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
19+
20+
## Query-backed forms
21+
22+
When query data supplies the initial values for an editable form, do not copy it into draft state in an Effect. Render loading chrome in an outer component, then mount a keyed form child once data exists and initialize its state lazily from props. Key by the resource identity so every related draft, dialog, and upload state resets together when the resource changes. Keep independent queries in the outer component to preserve parallel fetching.

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ vi.mock('@/components/settings/navigation', () => ({
4040
getOrganizationSettingsFeatures: vi.fn(() => ({})),
4141
isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable,
4242
resolveWorkspaceNavigation: mockResolveWorkspaceNavigation,
43+
workspaceSectionUsesPermissionConfig: vi.fn((section: string) =>
44+
['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section)
45+
),
4346
}))
4447

4548
vi.mock('@/lib/auth', () => ({
@@ -84,7 +87,7 @@ const { mockGetQueryClient, mockPrefetchGeneralSettings } = vi.hoisted(() => ({
8487
}))
8588

8689
const { mockSections, mockAliases } = vi.hoisted(() => ({
87-
mockSections: ['general', 'billing', 'secrets', 'sessions', 'admin'],
90+
mockSections: ['general', 'billing', 'secrets', 'sessions', 'admin', 'teammates'],
8891
/** Mirrors the real alias table so a legacy segment behaves here as it does in production. */
8992
mockAliases: {
9093
subscription: 'billing',
@@ -136,6 +139,21 @@ const PERSONAL_HOST_CONTEXT = {
136139
},
137140
}
138141

142+
const ORGANIZATION_HOST_CONTEXT = {
143+
workspace: {
144+
id: 'workspace-b',
145+
billedAccountUserId: 'owner-b',
146+
},
147+
hostOrganizationId: 'organization-b',
148+
ownerBilling: {
149+
isEnterprise: true,
150+
},
151+
viewer: {
152+
permission: 'admin',
153+
isHostOrganizationAdmin: true,
154+
},
155+
}
156+
139157
function pageProps(section: string) {
140158
return {
141159
params: Promise.resolve({ workspaceId: 'workspace-b', section }),
@@ -194,6 +212,43 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
194212
expect(mockPrefetchGeneralSettings).not.toHaveBeenCalled()
195213
})
196214

215+
it('resolves a permission group only when its config can hide the requested section', async () => {
216+
mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT)
217+
mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'teammates' }])
218+
219+
await WorkspaceSettingsSectionPage(pageProps('teammates'))
220+
221+
expect(mockResolveWorkspaceGroup).not.toHaveBeenCalled()
222+
223+
mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }])
224+
await WorkspaceSettingsSectionPage(pageProps('secrets'))
225+
226+
expect(mockResolveWorkspaceGroup).toHaveBeenCalledTimes(1)
227+
expect(mockResolveWorkspaceGroup).toHaveBeenCalledWith(
228+
'viewer-a',
229+
'organization-b',
230+
'workspace-b'
231+
)
232+
})
233+
234+
it('overlaps general-settings hydration with the organization section gate', async () => {
235+
let resolveCanOpenSection: ((value: boolean) => void) | undefined
236+
mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT)
237+
mockCanOpenOrganizationSettingsSection.mockReturnValue(
238+
new Promise<boolean>((resolve) => {
239+
resolveCanOpenSection = resolve
240+
})
241+
)
242+
243+
const render = WorkspaceSettingsSectionPage(pageProps('billing'))
244+
await vi.waitFor(() => expect(mockCanOpenOrganizationSettingsSection).toHaveBeenCalledTimes(1))
245+
246+
expect(mockPrefetchGeneralSettings).toHaveBeenCalledWith(expect.any(QueryClient), 'viewer-a')
247+
248+
resolveCanOpenSection?.(true)
249+
await render
250+
})
251+
197252
it('gates the hydration on the resolved section, not the raw segment', async () => {
198253
// `/settings/subscription` is a legacy link for billing, which does read the key. Billing on
199254
// a personal workspace is only reachable by the billed account owner.
@@ -210,5 +265,6 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
210265
await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow(
211266
'NEXT_NOT_FOUND'
212267
)
268+
expect(mockPrefetchGeneralSettings).not.toHaveBeenCalled()
213269
})
214270
})

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type OrganizationSettingsSection,
99
resolveWorkspaceNavigation,
1010
type WorkspaceSettingsSection,
11+
workspaceSectionUsesPermissionConfig,
1112
} from '@/components/settings/navigation'
1213
import { getSession } from '@/lib/auth'
1314
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
@@ -113,6 +114,16 @@ export default async function WorkspaceSettingsSectionPage({
113114
if (!hostContext) notFound()
114115
if (requiresPlatformAdmin && !isViewerPlatformAdmin) notFound()
115116

117+
const queryClient = getQueryClient()
118+
/**
119+
* Start the viewer-scoped prefetch as soon as workspace access is established. Organization
120+
* and section-entitlement gates remain authoritative, but their independent reads no longer
121+
* serialize in front of this data. The promise is still awaited before dehydration below.
122+
*/
123+
const generalSettingsPrefetch = GENERAL_SETTINGS_SECTIONS.has(parsed)
124+
? prefetchGeneralSettings(queryClient, session.user.id)
125+
: Promise.resolve()
126+
116127
const workspaceSection = WORKSPACE_SECTION_MAP[parsed]
117128
if (workspaceSection) {
118129
/**
@@ -130,12 +141,14 @@ export default async function WorkspaceSettingsSectionPage({
130141
* check it could not act on. Passing `false` elsewhere is safe in the one direction that
131142
* matters: it can only remove `forks` from a list this gate is not asking about.
132143
*
133-
* `permissionConfig` is deliberately NOT narrowed the same way. Its keys hide sections, so
134-
* skipping the lookup for a section that turns out to be config-gated would reveal it
135-
* fail-open, where the others fail closed.
144+
* Permission-group config is narrowed by the same policy map that hides navigation items.
145+
* Every other section is independent of that config, so resolving the viewer's group for it
146+
* can never change this gate's answer.
136147
*/
137148
const [permissionGroup, forksAvailable] = await Promise.all([
138-
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
149+
hostContext.hostOrganizationId &&
150+
hostContext.ownerBilling.isEnterprise &&
151+
workspaceSectionUsesPermissionConfig(workspaceSection)
139152
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
140153
: null,
141154
workspaceSection === 'forks'
@@ -206,17 +219,14 @@ export default async function WorkspaceSettingsSectionPage({
206219
}
207220
}
208221

209-
const queryClient = getQueryClient()
210222
/**
211223
* Scoped to the sections that actually read the key. The prefetch has to be awaited — an
212224
* unsettled query is dropped from the dehydrated payload, so firing and forgetting would
213225
* waterfall anyway — which means running it unconditionally charged the other ~25 sections
214226
* a blocking round-trip for a cache entry they never touch. The viewer's profile is seeded
215227
* by the workspace layout under a different key and is not repeated here.
216228
*/
217-
if (GENERAL_SETTINGS_SECTIONS.has(parsed)) {
218-
await prefetchGeneralSettings(queryClient)
219-
}
229+
await generalSettingsPrefetch
220230

221231
return (
222232
<HydrationBoundary state={dehydrate(queryClient)}>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { QueryClient } from '@tanstack/react-query'
5+
import { describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetUserSettings } = vi.hoisted(() => ({
8+
mockGetUserSettings: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/users/queries', () => ({
12+
getUserSettings: mockGetUserSettings,
13+
}))
14+
15+
import { prefetchGeneralSettings } from '@/app/workspace/[workspaceId]/settings/[section]/prefetch'
16+
import { generalSettingsKeys } from '@/hooks/queries/general-settings'
17+
18+
describe('prefetchGeneralSettings', () => {
19+
it('uses the authenticated viewer id supplied by the route', async () => {
20+
mockGetUserSettings.mockResolvedValue({
21+
autoConnect: true,
22+
superUserModeEnabled: false,
23+
mothershipEnvironment: 'prod',
24+
theme: 'system',
25+
telemetryEnabled: true,
26+
billingUsageNotificationsEnabled: true,
27+
errorNotificationsEnabled: true,
28+
snapToGridSize: 0,
29+
showActionBar: true,
30+
autoFocusOnClick: true,
31+
copilotAutoAllowedTools: [],
32+
timezone: null,
33+
})
34+
const queryClient = new QueryClient()
35+
36+
await prefetchGeneralSettings(queryClient, 'viewer-a')
37+
38+
expect(mockGetUserSettings).toHaveBeenCalledWith('viewer-a')
39+
expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({
40+
theme: 'system',
41+
telemetryEnabled: true,
42+
})
43+
})
44+
})

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { QueryClient } from '@tanstack/react-query'
2-
import { getSession } from '@/lib/auth'
32
import { getUserSettings } from '@/lib/users/queries'
43
import {
54
GENERAL_SETTINGS_STALE_TIME,
@@ -13,16 +12,19 @@ import {
1312
* Uses the same query key and mapper as the client `useGeneralSettings` hook, so the
1413
* hydrated entry is indistinguishable from one a client fetch produced.
1514
*
16-
* Callers must `await` this. Only a settled query is dehydrated, so an unawaited prefetch
17-
* is dropped from the payload entirely and the panel waterfalls on every load as if it had
18-
* never been prefetched.
15+
* The authenticated caller supplies the viewer ID it already resolved. Re-reading the session
16+
* inside the query would add another dependency to a prefetch that is deliberately started as
17+
* soon as workspace access succeeds.
18+
*
19+
* Callers must await the returned promise before dehydration. Only a settled query is included
20+
* by the current dehydration policy, so dropping the promise would leave the panel to fetch on
21+
* the client as if it had never been prefetched.
1922
*/
20-
export function prefetchGeneralSettings(queryClient: QueryClient) {
23+
export function prefetchGeneralSettings(queryClient: QueryClient, userId: string) {
2124
return queryClient.prefetchQuery({
2225
queryKey: generalSettingsKeys.settings(),
2326
queryFn: async () => {
24-
const session = await getSession()
25-
const data = await getUserSettings(session?.user?.id ?? null)
27+
const data = await getUserSettings(userId)
2628
return mapGeneralSettingsResponse(data)
2729
},
2830
staleTime: GENERAL_SETTINGS_STALE_TIME,

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,10 @@ const Terminal = dynamic(() =>
115115
(m) => m.Terminal
116116
)
117117
)
118-
const WhitelabelingSettings = dynamic(
119-
() =>
120-
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
121-
(m) => m.WhitelabelingSettings
122-
),
123-
{ ssr: false }
118+
const WhitelabelingSettings = dynamic(() =>
119+
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
120+
(m) => m.WhitelabelingSettings
121+
)
124122
)
125123

126124
interface SettingsPageProps {

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
mockPersonalQuery,
1111
mockUpdateOrganizationLimit,
1212
mockUpdateUserLimit,
13+
mockUseInvoices,
1314
mockUseOrganizationBilling,
1415
mockUseSubscriptionData,
1516
mockUseUsageLimitData,
@@ -18,6 +19,7 @@ const {
1819
mockPersonalQuery: { current: null as unknown },
1920
mockUpdateOrganizationLimit: vi.fn(),
2021
mockUpdateUserLimit: vi.fn(),
22+
mockUseInvoices: vi.fn(),
2123
mockUseOrganizationBilling: vi.fn(),
2224
mockUseSubscriptionData: vi.fn(),
2325
mockUseUsageLimitData: vi.fn(),
@@ -106,7 +108,10 @@ vi.mock('@/hooks/queries/organization', () => ({
106108
}))
107109

108110
vi.mock('@/hooks/queries/subscription', () => ({
109-
useInvoices: () => ({ data: { invoices: [], hasMore: false } }),
111+
useInvoices: (...args: unknown[]) => {
112+
mockUseInvoices(...args)
113+
return { data: { invoices: [], hasMore: false } }
114+
},
110115
useOpenBillingPortal: () => ({ isPending: false, mutate: vi.fn() }),
111116
useSubscriptionData: (...args: unknown[]) => {
112117
mockUseSubscriptionData(...args)
@@ -276,6 +281,10 @@ describe('Billing payer scope', () => {
276281
expect(mockUseSubscriptionData).toHaveBeenCalledWith(
277282
expect.objectContaining({ enabled: false })
278283
)
284+
expect(mockUseInvoices).toHaveBeenCalledWith({
285+
context: 'organization',
286+
organizationId: 'org-target',
287+
})
279288
expect(mockUseUsageLimitData).not.toHaveBeenCalled()
280289
expect(
281290
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
@@ -336,6 +345,10 @@ describe('Billing payer scope', () => {
336345

337346
expect(container.textContent).toContain('Personal Free plan')
338347
expect(container.querySelector('main > p')).toBeNull()
348+
expect(mockUseInvoices).toHaveBeenCalledWith({
349+
context: 'user',
350+
organizationId: undefined,
351+
})
339352
})
340353

341354
it('renders an explicit free organization state without subscription controls', async () => {

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,16 @@ export function Billing({
199199
const isTeamAdmin = isOrgAdminRole(userRole)
200200
const shouldUseOrganizationBillingContext = isOrganizationScope
201201

202+
/**
203+
* Invoice lookup is safe to start with the payer query: the endpoint returns an empty list
204+
* when the payer has no Stripe customer. Waiting to derive `isFree` serialized two independent
205+
* requests for every paid account and organization.
206+
*/
202207
const { data: invoicesData } = useInvoices({
203208
context: shouldUseOrganizationBillingContext ? 'organization' : 'user',
204209
organizationId: shouldUseOrganizationBillingContext
205210
? (billingOrganizationId ?? undefined)
206211
: undefined,
207-
enabled: !subscription.isFree,
208212
})
209213

210214
const planIncludedAmount =

apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,19 @@ describe('Browser settings', () => {
449449
])
450450
})
451451

452+
it('reserves the real header actions while preferences are loading', async () => {
453+
const bridge = createBridge()
454+
bridge.settings.getPreferences = vi.fn(() => new Promise(() => {}))
455+
mockBridge.current = bridge
456+
457+
await render()
458+
459+
const actions = [...container.querySelectorAll<HTMLButtonElement>('header button')]
460+
expect(actions.map((button) => button.textContent)).toEqual(['Passwords', 'Clear all'])
461+
expect(actions.every((button) => button.disabled)).toBe(true)
462+
expect(container.querySelector('section[aria-label="General"]')).toBeNull()
463+
})
464+
452465
it('lists each data type as a standard settings row in one section', async () => {
453466
await render()
454467

0 commit comments

Comments
 (0)