Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .agents/skills/react-query-best-practices/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ Read these before analyzing:
- 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
- `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys
- Use `enabled` to prevent queries from running without required params
- 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.
- 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.
- 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.
- Server prefetches must call the authorized use case, apply the route presenter/response schema, and reuse the client's exact key, mapper, and stale time. Keep all fallible auth/read/parse work inside `queryFn` so an optional warm cannot fail the page, and never bypass a route that redacts fields.

### Mutations
- Use `onSettled` (not `onSuccess`) for cache reconciliation — it fires on both success and error
Expand All @@ -46,7 +50,7 @@ Read these before analyzing:
- Never copy query data into useState. Use query data directly in components.
- Never copy query data into Zustand stores (exception: mutation callbacks that coordinate cross-store state like temp ID replacement)
- The query cache is not a local state manager — `setQueryData` is for optimistic updates only
- Forms are the one deliberate exception: copy server data into local form state with `staleTime: Infinity`
- 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.

## Steps

Expand Down
4 changes: 4 additions & 0 deletions .agents/skills/you-might-not-need-an-effect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ Steps:
1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines
2. Analyze the specified scope for useEffect anti-patterns
3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.

## Query-backed forms

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.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ vi.mock('@/components/settings/navigation', () => ({
getOrganizationSettingsFeatures: vi.fn(() => ({})),
isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable,
resolveWorkspaceNavigation: mockResolveWorkspaceNavigation,
workspaceSectionUsesPermissionConfig: vi.fn((section: string) =>
['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section)
),
}))

vi.mock('@/lib/auth', () => ({
Expand Down Expand Up @@ -78,13 +81,13 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: mockGetQueryClient,
}))

const { mockGetQueryClient, mockPrefetchGeneralSettings } = vi.hoisted(() => ({
const { mockGetQueryClient, mockSectionPrefetch } = vi.hoisted(() => ({
mockGetQueryClient: vi.fn(),
mockPrefetchGeneralSettings: vi.fn(),
mockSectionPrefetch: vi.fn(),
}))

const { mockSections, mockAliases } = vi.hoisted(() => ({
mockSections: ['general', 'billing', 'secrets', 'sessions', 'admin'],
mockSections: ['general', 'billing', 'secrets', 'sessions', 'admin', 'teammates'],
/** Mirrors the real alias table so a legacy segment behaves here as it does in production. */
mockAliases: {
subscription: 'billing',
Expand All @@ -111,7 +114,13 @@ vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
}))

vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({
prefetchGeneralSettings: mockPrefetchGeneralSettings,
/** Mirrors the real registry's keys so a section absent from it prefetches nothing. */
SECTION_PREFETCHERS: {
general: mockSectionPrefetch,
billing: mockSectionPrefetch,
admin: mockSectionPrefetch,
'credential-groups': mockSectionPrefetch,
},
}))

vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({
Expand All @@ -136,6 +145,21 @@ const PERSONAL_HOST_CONTEXT = {
},
}

const ORGANIZATION_HOST_CONTEXT = {
workspace: {
id: 'workspace-b',
billedAccountUserId: 'owner-b',
},
hostOrganizationId: 'organization-b',
ownerBilling: {
isEnterprise: true,
},
viewer: {
permission: 'admin',
isHostOrganizationAdmin: true,
},
}

function pageProps(section: string) {
return {
params: Promise.resolve({ workspaceId: 'workspace-b', section }),
Expand Down Expand Up @@ -181,27 +205,66 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled()
})

it('hydrates general settings only for the sections whose body reads them', async () => {
// The saving this gate exists for: the other ~25 sections no longer block on a query they
// never touch. `general` still does, and so does an alias that resolves onto the set.
it('prefetches only for the sections that declare a prefetcher', async () => {
// The saving the registry exists for: a section with no entry blocks on nothing.
mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }])

await WorkspaceSettingsSectionPage(pageProps('general'))
expect(mockPrefetchGeneralSettings).toHaveBeenCalledTimes(1)
expect(mockSectionPrefetch).toHaveBeenCalledTimes(1)

mockSectionPrefetch.mockClear()
await WorkspaceSettingsSectionPage(pageProps('secrets'))
expect(mockSectionPrefetch).not.toHaveBeenCalled()
})

it('resolves a permission group only when its config can hide the requested section', async () => {
mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT)
mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'teammates' }])

await WorkspaceSettingsSectionPage(pageProps('teammates'))

expect(mockResolveWorkspaceGroup).not.toHaveBeenCalled()

mockPrefetchGeneralSettings.mockClear()
mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }])
await WorkspaceSettingsSectionPage(pageProps('secrets'))
expect(mockPrefetchGeneralSettings).not.toHaveBeenCalled()

expect(mockResolveWorkspaceGroup).toHaveBeenCalledTimes(1)
expect(mockResolveWorkspaceGroup).toHaveBeenCalledWith(
'viewer-a',
'organization-b',
'workspace-b'
)
})

it('overlaps the section prefetch with the organization section gate', async () => {
let resolveCanOpenSection: ((value: boolean) => void) | undefined
mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT)
mockCanOpenOrganizationSettingsSection.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCanOpenSection = resolve
})
)

const render = WorkspaceSettingsSectionPage(pageProps('billing'))
await vi.waitFor(() => expect(mockCanOpenOrganizationSettingsSection).toHaveBeenCalledTimes(1))

expect(mockSectionPrefetch).toHaveBeenCalledWith(
expect.any(QueryClient),
expect.objectContaining({ userId: 'viewer-a', workspaceId: 'workspace-b' })
)

resolveCanOpenSection?.(true)
await render
})

it('gates the hydration on the resolved section, not the raw segment', async () => {
it('selects the prefetcher by resolved section, not the raw segment', async () => {
// `/settings/subscription` is a legacy link for billing, which does read the key. Billing on
// a personal workspace is only reachable by the billed account owner.
mockGetSession.mockResolvedValue({ user: { id: 'owner-b' } })

await WorkspaceSettingsSectionPage(pageProps('subscription'))

expect(mockPrefetchGeneralSettings).toHaveBeenCalledTimes(1)
expect(mockSectionPrefetch).toHaveBeenCalledTimes(1)
})

it('keeps inaccessible workspaces fail-fast', async () => {
Expand All @@ -210,5 +273,6 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow(
'NEXT_NOT_FOUND'
)
expect(mockSectionPrefetch).not.toHaveBeenCalled()
})
})
52 changes: 22 additions & 30 deletions apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type OrganizationSettingsSection,
resolveWorkspaceNavigation,
type WorkspaceSettingsSection,
workspaceSectionUsesPermissionConfig,
} from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
Expand All @@ -23,7 +24,7 @@ import {
} from '@/app/workspace/[workspaceId]/settings/navigation'
import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check'
import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz'
import { prefetchGeneralSettings } from './prefetch'
import { SECTION_PREFETCHERS } from './prefetch'
import { SettingsPage } from './settings'

interface WorkspaceSettingsSectionPageProps {
Expand Down Expand Up @@ -59,20 +60,6 @@ const ORGANIZATION_SECTION_MAP: Partial<Record<SettingsSection, OrganizationSett
whitelabeling: 'whitelabeling',
}

/**
* Sections whose first paint reads the general-settings query.
*
* Their bodies default a missing value (`?? true` / `?? false`) and drive a switch off it, so
* without a hydrated entry they paint the fallback and visibly flip when the client fetch
* lands. The workspace layout's `SettingsLoader` warms this key only after hydration, which
* covers client navigation but not a direct load of one of these sections.
*/
const GENERAL_SETTINGS_SECTIONS: ReadonlySet<SettingsSection> = new Set([
'general',
'billing',
'admin',
])

/**
* Settings availability varies across workspaces, so a preserved section may
* need to land on the destination workspace's universally available page.
Expand Down Expand Up @@ -113,6 +100,18 @@ export default async function WorkspaceSettingsSectionPage({
if (!hostContext) notFound()
if (requiresPlatformAdmin && !isViewerPlatformAdmin) notFound()

const queryClient = getQueryClient()
/**
* Start the viewer-scoped prefetch as soon as workspace access is established. Organization
* and section-entitlement gates remain authoritative, but their independent reads no longer
* serialize in front of this data. The promise is still awaited before dehydration below.
*/
const sectionPrefetch =
SECTION_PREFETCHERS[parsed]?.(queryClient, {
workspaceId,
userId: session.user.id,
}) ?? Promise.resolve()

const workspaceSection = WORKSPACE_SECTION_MAP[parsed]
if (workspaceSection) {
/**
Expand All @@ -130,12 +129,14 @@ export default async function WorkspaceSettingsSectionPage({
* check it could not act on. Passing `false` elsewhere is safe in the one direction that
* matters: it can only remove `forks` from a list this gate is not asking about.
*
* `permissionConfig` is deliberately NOT narrowed the same way. Its keys hide sections, so
* skipping the lookup for a section that turns out to be config-gated would reveal it
* fail-open, where the others fail closed.
* Permission-group config is narrowed by the same policy map that hides navigation items.
* Every other section is independent of that config, so resolving the viewer's group for it
* can never change this gate's answer.
*/
const [permissionGroup, forksAvailable] = await Promise.all([
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
hostContext.hostOrganizationId &&
hostContext.ownerBilling.isEnterprise &&
workspaceSectionUsesPermissionConfig(workspaceSection)
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
: null,
workspaceSection === 'forks'
Expand Down Expand Up @@ -206,17 +207,8 @@ export default async function WorkspaceSettingsSectionPage({
}
}

const queryClient = getQueryClient()
/**
* Scoped to the sections that actually read the key. The prefetch has to be awaited — an
* unsettled query is dropped from the dehydrated payload, so firing and forgetting would
* waterfall anyway — which means running it unconditionally charged the other ~25 sections
* a blocking round-trip for a cache entry they never touch. The viewer's profile is seeded
* by the workspace layout under a different key and is not repeated here.
*/
if (GENERAL_SETTINGS_SECTIONS.has(parsed)) {
await prefetchGeneralSettings(queryClient)
}
/** Awaiting is required because unsettled queries are omitted from dehydration. */
await sectionPrefetch

return (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @vitest-environment node
*/
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetUserSettings, mockExecute, mockAuthenticate } = vi.hoisted(() => ({
mockGetUserSettings: vi.fn(),
mockExecute: vi.fn(),
mockAuthenticate: vi.fn(),
}))

vi.mock('@/lib/users/queries', () => ({
getUserSettings: mockGetUserSettings,
}))

vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
listCredentialGroupSettings: { execute: mockExecute },
}))

vi.mock('@/lib/api/server/routes/internal-json-route', () => ({
internalSessionAuth: { authenticate: mockAuthenticate },
}))

import {
prefetchGeneralSettings,
SECTION_PREFETCHERS,
} from '@/app/workspace/[workspaceId]/settings/[section]/prefetch'
import { generalSettingsKeys } from '@/hooks/queries/general-settings'
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'

describe('prefetchGeneralSettings', () => {
it('uses the authenticated viewer id supplied by the route', async () => {
mockGetUserSettings.mockResolvedValue({
autoConnect: true,
superUserModeEnabled: false,
mothershipEnvironment: 'prod',
theme: 'system',
telemetryEnabled: true,
billingUsageNotificationsEnabled: true,
errorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
autoFocusOnClick: true,
copilotAutoAllowedTools: [],
timezone: null,
})
const queryClient = new QueryClient()

await prefetchGeneralSettings(queryClient, 'viewer-a')

expect(mockGetUserSettings).toHaveBeenCalledWith('viewer-a')
expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({
theme: 'system',
telemetryEnabled: true,
})
})
})

describe('credential-groups prefetch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthenticate.mockResolvedValue({ kind: 'session', userId: 'u1', sessionId: 's1' })
})

it('hydrates the key the panel subscribes to, through the authorized use case', async () => {
const credentialGroup = {
id: 'g1',
workspaceId: 'w1',
name: 'Engineering',
description: null,
options: [],
status: 'active',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
mockExecute.mockResolvedValue({ credentialGroups: [{ ...credentialGroup, internal: true }] })
const queryClient = new QueryClient()

await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
workspaceId: 'w1',
userId: 'u1',
})

expect(mockExecute).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'u1', sessionId: 's1' },
input: { workspaceId: 'w1' },
})
expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual([credentialGroup])
})

it('leaves the cache empty when the use case denies the viewer', async () => {
mockExecute.mockRejectedValue(Object.assign(new Error('forbidden'), { code: 'forbidden' }))
const queryClient = new QueryClient()

await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
workspaceId: 'w1',
userId: 'u1',
})

expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined()
})

it('leaves the cache empty when session authentication fails', async () => {
mockAuthenticate.mockRejectedValue(new Error('unauthenticated'))
const queryClient = new QueryClient()

await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
workspaceId: 'w1',
userId: 'u1',
})

expect(mockExecute).not.toHaveBeenCalled()
expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined()
})
})
Loading
Loading