From c92fedd030e92b8c2b8ffb969433a1469a53cbdb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 21 Aug 2026 19:37:17 -0700 Subject: [PATCH 1/6] improvement(settings): make settings section navigation feel instant Every settings tab switch ran four sequential round-trips with no visual feedback: a cold RSC request, then the panel render, then the section's lazy chunk, then its queries. The heading was pushed up from the section body, so the most static thing on the page arrived last and visibly blanked between sections. - resolve the section heading in the route layout from its navigation entry, so it paints with the shell instead of after the body's chunk - add loading.tsx to all four settings planes, so a click commits the navigation immediately instead of holding the outgoing section - give every code-split section a shared skeleton fallback, reused by the route boundary and the in-page Suspense boundary - prefetch the route payload on sidebar hover/focus; the rows are buttons for the unsaved-changes guard, so they never got Next's prefetch - scope the general-settings server prefetch to the three sections that read it, instead of blocking all 28 on it - set staleTimes so returning to a tab reuses the client router cache rather than re-running the access gate - warm workspace credentials under the type the secrets panel queries; the previous warm wrote a different cache entry and never landed --- .../account/settings/[section]/loading.tsx | 13 ++ .../app/account/settings/[section]/page.tsx | 9 +- .../settings/[section]/loading.tsx | 13 ++ .../selfhost/settings/[section]/loading.tsx | 13 ++ .../app/selfhost/settings/[section]/page.tsx | 7 +- .../settings/[section]/layout.tsx | 18 +- .../settings/[section]/loading.tsx | 12 + .../settings/[section]/page.test.tsx | 10 +- .../[workspaceId]/settings/[section]/page.tsx | 61 ++--- .../settings/[section]/settings.tsx | 216 +++++++++++------- .../[workspaceId]/settings/navigation.ts | 42 +++- .../settings-sidebar/settings-sidebar.tsx | 70 +++--- .../settings/account-settings-renderer.tsx | 41 ++-- apps/sim/components/settings/lazy-section.tsx | 22 ++ apps/sim/components/settings/navigation.ts | 27 +++ .../organization-settings-renderer.tsx | 67 ++++-- .../settings/selfhost-settings-renderer.tsx | 21 +- .../settings/settings-header-shell.test.tsx | 64 ++++++ .../components/settings/settings-header.tsx | 33 ++- .../settings/settings-section-skeleton.tsx | 26 +++ .../components/settings/settings-sidebar.tsx | 8 + .../settings/standalone-settings-shell.tsx | 3 +- apps/sim/hooks/queries/credentials.ts | 10 +- .../utils/fetch-workspace-credentials.ts | 6 +- apps/sim/next.config.ts | 17 ++ 25 files changed, 628 insertions(+), 201 deletions(-) create mode 100644 apps/sim/app/account/settings/[section]/loading.tsx create mode 100644 apps/sim/app/organization/[organizationId]/settings/[section]/loading.tsx create mode 100644 apps/sim/app/selfhost/settings/[section]/loading.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx create mode 100644 apps/sim/components/settings/lazy-section.tsx create mode 100644 apps/sim/components/settings/settings-section-skeleton.tsx diff --git a/apps/sim/app/account/settings/[section]/loading.tsx b/apps/sim/app/account/settings/[section]/loading.tsx new file mode 100644 index 00000000000..033c0c40593 --- /dev/null +++ b/apps/sim/app/account/settings/[section]/loading.tsx @@ -0,0 +1,13 @@ +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' + +/** + * Route-transition fallback for every account settings section. + * + * Without a loading boundary the App Router holds the outgoing section on screen until the + * incoming page's access gate resolves, so a click reads as a dead click. This commits the + * navigation immediately — the shell's heading updates with it — and lets the gate resolve + * behind the placeholder. + */ +export default function AccountSettingsSectionLoading() { + return +} diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 71f10cbea0e..bb5d551fdcb 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -9,6 +9,7 @@ import { getSettingsSectionMeta, parseSettingsPathSection, } from '@/components/settings/navigation' +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' import { getSession } from '@/lib/auth' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { isPlatformAdmin } from '@/lib/permissions/super-user' @@ -53,12 +54,12 @@ export default async function AccountSettingsSectionPage({ /** * Sections read URL query params via nuqs (which uses `useSearchParams` - * internally), so the renderer must sit under a Suspense boundary. The - * `null` fallback matches the existing visual behavior — the sections are - * `next/dynamic` components that render nothing while their chunk loads. + * internally), so the renderer must sit under a Suspense boundary. It shares the + * route's loading placeholder, so a section resolving its params looks the same as one + * whose chunk is still in flight. */ return ( - + }> ) diff --git a/apps/sim/app/organization/[organizationId]/settings/[section]/loading.tsx b/apps/sim/app/organization/[organizationId]/settings/[section]/loading.tsx new file mode 100644 index 00000000000..d46cb7740c0 --- /dev/null +++ b/apps/sim/app/organization/[organizationId]/settings/[section]/loading.tsx @@ -0,0 +1,13 @@ +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' + +/** + * Route-transition fallback for every organization settings section. + * + * Without a loading boundary the App Router holds the outgoing section on screen until the + * incoming page's access gate resolves, so a click reads as a dead click. This commits the + * navigation immediately — the shell's heading updates with it — and lets the gate resolve + * behind the placeholder. + */ +export default function OrganizationSettingsSectionLoading() { + return +} diff --git a/apps/sim/app/selfhost/settings/[section]/loading.tsx b/apps/sim/app/selfhost/settings/[section]/loading.tsx new file mode 100644 index 00000000000..d82a2e71137 --- /dev/null +++ b/apps/sim/app/selfhost/settings/[section]/loading.tsx @@ -0,0 +1,13 @@ +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' + +/** + * Route-transition fallback for every self-hosted settings section. + * + * Without a loading boundary the App Router holds the outgoing section on screen until the + * incoming page's access gate resolves, so a click reads as a dead click. This commits the + * navigation immediately — the shell's heading updates with it — and lets the gate resolve + * behind the placeholder. + */ +export default function SelfHostSettingsSectionLoading() { + return +} diff --git a/apps/sim/app/selfhost/settings/[section]/page.tsx b/apps/sim/app/selfhost/settings/[section]/page.tsx index eacc4d54c24..0ef0f55125a 100644 --- a/apps/sim/app/selfhost/settings/[section]/page.tsx +++ b/apps/sim/app/selfhost/settings/[section]/page.tsx @@ -8,6 +8,7 @@ import { SELFHOST_SETTINGS_ITEMS, } from '@/components/settings/navigation' import { SelfHostSettingsRenderer } from '@/components/settings/selfhost-settings-renderer' +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' import { getSession } from '@/lib/auth' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' @@ -46,10 +47,12 @@ export default async function SelfHostSettingsSectionPage({ /** * Sections read URL query params via nuqs (which uses `useSearchParams` - * internally), so the renderer must sit under a Suspense boundary. + * internally), so the renderer must sit under a Suspense boundary. It shares the + * route's loading placeholder, so a section resolving its params looks the same as one + * whose chunk is still in flight. */ return ( - + }> ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index 6ab029f7f5b..5866e00e874 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -2,17 +2,31 @@ import { SettingsHeaderProvider, SettingsHeaderShell, } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' /** * Persistent chrome for the settings panel pages. The header bar, title, * description, scroll region, and centered column live in the shell and stay * mounted across section navigation — only the body swaps. Scoped to `[section]` * so detail routes (e.g. `secrets/[credentialId]`) keep their own chrome. + * + * The heading is resolved here rather than pushed up from the section body, so it + * paints with the shell instead of waiting on the body's lazily-loaded chunk. An + * unknown segment resolves to `null` and the page below it calls `notFound()`. */ -export default function SettingsSectionLayout({ children }: { children: React.ReactNode }) { +export default async function SettingsSectionLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ section: string }> +}) { + const { section } = await params + const meta = resolveSettingsSection(section)?.meta ?? null + return ( - {children} + {children} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx new file mode 100644 index 00000000000..91bbb5b9e14 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx @@ -0,0 +1,12 @@ +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' + +/** + * Route-transition fallback for every settings section. + * + * Without a loading boundary the App Router holds the outgoing section on screen until the + * incoming page's access gate resolves, so a click reads as a dead click. This commits the + * navigation immediately and lets the gate resolve behind it. + */ +export default function SettingsSectionLoading() { + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 9b54c76899d..c615e95c681 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -87,8 +87,16 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: vi.fn(), })) +const { mockSections } = vi.hoisted(() => ({ + mockSections: ['general', 'billing', 'secrets', 'sessions'], +})) + vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({ - allNavigationItems: [{ id: 'general' }, { id: 'billing' }, { id: 'secrets' }, { id: 'sessions' }], + allNavigationItems: mockSections.map((id) => ({ id })), + resolveSettingsSection: vi.fn((section: string) => { + const id = section === 'subscription' ? 'billing' : section + return mockSections.includes(id) ? { id, meta: { title: id } } : null + }), getSettingsSectionMeta: vi.fn(() => null), })) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 2d98d12c5d4..0ae02d8b96c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -9,6 +9,7 @@ import { resolveWorkspaceNavigation, type WorkspaceSettingsSection, } from '@/components/settings/navigation' +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' @@ -20,8 +21,7 @@ import { isPlatformAdmin } from '@/lib/permissions/super-user' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { - allNavigationItems, - getSettingsSectionMeta, + resolveSettingsSection, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' @@ -33,14 +33,6 @@ interface WorkspaceSettingsSectionPageProps { params: Promise<{ workspaceId: string; section: string }> } -const SECTION_ALIASES: Readonly> = { - subscription: 'billing', - team: 'organization', - 'api-keys': 'apikeys', - // Verified domains moved into the SSO page; keep old links working. - domains: 'sso', -} - const TOP_LEVEL_REDIRECTS: Readonly string>> = { integrations: (workspaceId) => `/workspace/${workspaceId}/integrations`, skills: (workspaceId) => `/workspace/${workspaceId}/skills`, @@ -77,12 +69,19 @@ const ORGANIZATION_SECTION_MAP: Partial item.id === normalized) - ? (normalized as SettingsSection) - : null -} +/** + * 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 = new Set([ + 'general', + 'billing', + 'admin', +]) /** * Settings availability varies across workspaces, so a preserved section may @@ -96,9 +95,7 @@ export async function generateMetadata({ params, }: WorkspaceSettingsSectionPageProps): Promise { const { section } = await params - const parsed = parseSection(section) - const meta = parsed ? getSettingsSectionMeta(parsed) : null - return { title: meta?.label ?? 'Settings' } + return { title: resolveSettingsSection(section)?.meta.title ?? 'Settings' } } export default async function WorkspaceSettingsSectionPage({ @@ -110,8 +107,9 @@ export default async function WorkspaceSettingsSectionPage({ const { workspaceId, section } = await params const topLevelHref = TOP_LEVEL_REDIRECTS[section]?.(workspaceId) if (topLevelHref) redirect(topLevelHref) - const parsed = parseSection(section) - if (!parsed) notFound() + const resolved = resolveSettingsSection(section) + if (!resolved) notFound() + const parsed = resolved.id const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) if (!hostContext) notFound() @@ -191,16 +189,25 @@ export default async function WorkspaceSettingsSectionPage({ const queryClient = getQueryClient() /** - * Awaited, not fired and forgotten: only a settled query is dehydrated, so an unawaited - * prefetch is dropped from the payload and the panel waterfalls anyway. The viewer's - * profile is already seeded by the workspace layout under the same key, so it is not - * repeated here. + * 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. */ - await prefetchGeneralSettings(queryClient) + if (GENERAL_SETTINGS_SECTIONS.has(parsed)) { + await prefetchGeneralSettings(queryClient) + } return ( - + {/* + Sections read URL query params via nuqs (which uses `useSearchParams` internally), + so the panel must sit under a Suspense boundary. It shares the route's loading + placeholder, so a section resolving its params looks the same as one whose chunk is + still in flight. + */} + }> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 9ba369ef92a..f7e4f745380 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' +import { SETTINGS_SECTION_LOADING_OPTIONS } from '@/components/settings/lazy-section' import { useSession } from '@/lib/auth/auth-client' import { captureEvent } from '@/lib/posthog/client' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -14,113 +15,174 @@ import { type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' -const Admin = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin) +const Admin = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin), + SETTINGS_SECTION_LOADING_OPTIONS ) -const ApiKeys = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( - (m) => m.ApiKeys - ) +const ApiKeys = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( + (m) => m.ApiKeys + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const BYOK = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK) +const BYOK = dynamic( + () => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks)) -const Secrets = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) +const Forks = dynamic( + () => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Sandboxes = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then( - (m) => m.Sandboxes - ) +const Secrets = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then( + (m) => m.Secrets + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const CustomTools = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then( - (m) => m.CustomTools - ) +const Sandboxes = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then( + (m) => m.Sandboxes + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Inbox = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox) +const CustomTools = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then( + (m) => m.CustomTools + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const MCP = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP) +const Inbox = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Mothership = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then( - (m) => m.Mothership - ) +const MCP = dynamic( + () => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP), + SETTINGS_SECTION_LOADING_OPTIONS ) -const RecentlyDeleted = dynamic(() => - import( - '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' - ).then((m) => m.RecentlyDeleted) +const Mothership = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then( + (m) => m.Mothership + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const SelfHost = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host').then( - (m) => m.SelfHost - ) +const RecentlyDeleted = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' + ).then((m) => m.RecentlyDeleted), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing) +const SelfHost = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host').then( + (m) => m.SelfHost + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Teammates = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then( - (m) => m.Teammates - ) +const Billing = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( + (m) => m.Billing + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const TeamManagement = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( - (m) => m.TeamManagement - ) +const Teammates = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then( + (m) => m.Teammates + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const WorkflowMcpServers = dynamic(() => - import( - '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' - ).then((m) => m.WorkflowMcpServers) +const TeamManagement = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/team-management/team-management' + ).then((m) => m.TeamManagement), + SETTINGS_SECTION_LOADING_OPTIONS ) -const AccessControl = dynamic(() => - import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) +const WorkflowMcpServers = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' + ).then((m) => m.WorkflowMcpServers), + SETTINGS_SECTION_LOADING_OPTIONS ) -const CustomBlocks = dynamic(() => - import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) +const AccessControl = dynamic( + () => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl), + SETTINGS_SECTION_LOADING_OPTIONS ) -const CredentialGroups = dynamic(() => - import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings) +const CustomBlocks = dynamic( + () => import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks), + SETTINGS_SECTION_LOADING_OPTIONS ) -const AuditLogs = dynamic(() => - import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) +const CredentialGroups = dynamic( + () => import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings), + SETTINGS_SECTION_LOADING_OPTIONS ) -const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) -const SessionPolicySettings = dynamic(() => - import('@/ee/session-policy/components/session-policy-settings').then( - (m) => m.SessionPolicySettings - ) +const AuditLogs = dynamic( + () => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs), + SETTINGS_SECTION_LOADING_OPTIONS ) -const DataRetentionSettings = dynamic(() => - import('@/ee/data-retention/components/data-retention-settings').then( - (m) => m.DataRetentionSettings - ) +const SSO = dynamic( + () => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), + SETTINGS_SECTION_LOADING_OPTIONS +) +const SessionPolicySettings = dynamic( + () => + import('@/ee/session-policy/components/session-policy-settings').then( + (m) => m.SessionPolicySettings + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const DataDrainsSettings = dynamic(() => - import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings) +const DataRetentionSettings = dynamic( + () => + import('@/ee/data-retention/components/data-retention-settings').then( + (m) => m.DataRetentionSettings + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Desktop = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then((m) => m.Desktop) +const DataDrainsSettings = dynamic( + () => + import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Browser = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/browser/browser').then((m) => m.Browser) +const Desktop = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then( + (m) => m.Desktop + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Terminal = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal').then( - (m) => m.Terminal - ) +const Browser = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/browser/browser').then( + (m) => m.Browser + ), + SETTINGS_SECTION_LOADING_OPTIONS +) +const Terminal = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal').then( + (m) => m.Terminal + ), + SETTINGS_SECTION_LOADING_OPTIONS ) const WhitelabelingSettings = dynamic( () => import('@/ee/whitelabeling/components/whitelabeling-settings').then( (m) => m.WhitelabelingSettings ), - { ssr: false } + { + ...SETTINGS_SECTION_LOADING_OPTIONS, + ssr: false, + } ) interface SettingsPageProps { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index d659a983c0b..57de4bd4a04 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -1,10 +1,12 @@ import { buildUnifiedSettingsNavigation, SETTINGS_NAVIGATION_BILLING_ENABLED, + toSettingsHeaderMeta, type UnifiedNavigationSection, type UnifiedSettingsNavigationItem, type UnifiedSettingsSection, } from '@/components/settings/navigation' +import type { SettingsHeaderMeta } from '@/components/settings/settings-header' export type SettingsSection = UnifiedSettingsSection @@ -23,6 +25,44 @@ export const sectionConfig: { key: NavigationSection; title: string }[] = [ export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation() +/** + * Catalog entries indexed by id. Every routed navigation resolves a section, so the + * lookup runs on each settings request rather than only on the ones that render a list. + * Built first-wins to match the `find` it replaces. + */ +const navigationItemsById = new Map() +for (const item of allNavigationItems) { + if (!navigationItemsById.has(item.id)) navigationItemsById.set(item.id, item) +} + +/** + * Section segments that are no longer canonical but must keep resolving, so old links + * and bookmarks survive. Kept beside the catalog because the route layout, the page's + * access gate, and `generateMetadata` all have to normalize a segment identically. + */ +const SECTION_ALIASES: Readonly> = { + subscription: 'billing', + team: 'organization', + 'api-keys': 'apikeys', + /** Verified domains moved into the SSO page. */ + domains: 'sso', +} + +export interface ResolvedSettingsSection { + id: SettingsSection + meta: SettingsHeaderMeta +} + +/** + * Normalizes a routed `[section]` segment to its catalog entry, or `null` when the + * segment names no known section. Availability is not considered here — whether the + * viewer may open a section is the page gate's decision, not the route's. + */ +export function resolveSettingsSection(section: string): ResolvedSettingsSection | null { + const item = navigationItemsById.get((SECTION_ALIASES[section] ?? section) as SettingsSection) + return item ? { id: item.id, meta: toSettingsHeaderMeta(item) } : null +} + /** * Title + description for a settings section, the single source of truth used by * `SettingsPanel` to render the page header. Falls back to `null` for sections @@ -31,6 +71,6 @@ export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigati export function getSettingsSectionMeta( section: SettingsSection ): { label: string; description: string; docsLink?: string } | null { - const item = allNavigationItems.find((navItem) => navItem.id === section) + const item = navigationItemsById.get(section) return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 3119c40fe2a..a8e7db9f3bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -9,7 +9,7 @@ import { cn, } from '@sim/emcn' import { ChevronLeft } from '@sim/emcn/icons' -import { useQueryClient } from '@tanstack/react-query' +import { type QueryClient, useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' import type { DesktopSettingsSurface } from '@/components/settings/navigation' import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' @@ -38,12 +38,34 @@ import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sideb import { useSSOProviders } from '@/ee/sso/hooks/sso' import { useForkingAvailable } from '@/ee/workspace-forking/hooks/use-forking-available' import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials' -import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings' +import { useGeneralSettings } from '@/hooks/queries/general-settings' import { useInboxConfig } from '@/hooks/queries/inbox' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' +/** + * Sections whose first paint waits on a query the sidebar is able to start early. + * + * Deliberately short: warming every section's queries on hover would trade a cold panel for a + * cold sidebar, and the rest are cheap enough to fetch on mount. The section's chunk is warmed + * separately, for all of them, via `sectionLoaders`. + * + * `general` is absent because it cannot help here — this sidebar only renders inside the + * workspace layout, whose `SettingsLoader` already holds a live observer on that key with an + * hour-long stale time, so `prefetchQuery` would short-circuit on every hover. + * + * The type argument is load-bearing: workspace credentials are cached per type, and the + * secrets panel subscribes to `env_workspace`. Warming the unfiltered list writes a different + * cache entry and leaves the panel to fetch cold anyway. + */ +const SECTION_QUERY_WARMERS: Partial< + Record void> +> = { + secrets: (queryClient, workspaceId) => + prefetchWorkspaceCredentials(queryClient, workspaceId, 'env_workspace'), +} + interface SettingsSidebarProps { isCollapsed?: boolean showCollapsedTooltips?: boolean @@ -226,36 +248,24 @@ export function SettingsSidebar({ return 'general' }, [pathname]) - const handlePrefetch = useCallback( - (itemId: string) => { - switch (itemId) { - case 'general': - prefetchGeneralSettings(queryClient) - void import('@/app/workspace/[workspaceId]/settings/components/general/general') - break - case 'secrets': - prefetchWorkspaceCredentials(queryClient, workspaceId) - void import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets') - break - case 'billing': - void import('@/app/workspace/[workspaceId]/settings/components/billing/billing') - break - case 'desktop': - void import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop') - break - case 'browser': - void import('@/app/workspace/[workspaceId]/settings/components/browser/browser') - break - case 'terminal': - void import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal') - break - } - }, - [queryClient, workspaceId] - ) - const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() + const handlePrefetch = (section: SettingsSection) => { + /** + * The route payload is the slowest hop behind a section — the access gate runs on the + * server — and the one a row can never get for free, because Next only auto-prefetches + * `` and these rows are buttons that must run the unsaved-changes guard first. + * + * The section's JS chunk is deliberately NOT warmed here. Naming those chunks from this + * file puts every settings section into the module graph of the workflow editor and the + * other workspace routes this sidebar ships with — ~200 modules on the app's hottest + * pages to save one hop on a page they are not on. `dynamic()`'s shared skeleton covers + * that hop gracefully instead. + */ + router.prefetch(getSettingsHref({ section })) + SECTION_QUERY_WARMERS[section]?.(queryClient, workspaceId) + } + const handleBack = useCallback(() => { requestLeave(() => { router.push(popSettingsReturnUrl(`/workspace/${workspaceId}`)) diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 7cefeba5d47..bc50000d723 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -3,29 +3,38 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' +import { SETTINGS_SECTION_LOADING_OPTIONS } from '@/components/settings/lazy-section' import type { AccountSettingsSection } from '@/components/settings/navigation' import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( - (module) => module.Billing - ) +const Billing = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( + (module) => module.Billing + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const ApiKeys = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( - (module) => module.ApiKeys - ) +const ApiKeys = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( + (module) => module.ApiKeys + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Admin = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then( - (module) => module.Admin - ) +const Admin = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then( + (module) => module.Admin + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Mothership = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then( - (module) => module.Mothership - ) +const Mothership = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then( + (module) => module.Mothership + ), + SETTINGS_SECTION_LOADING_OPTIONS ) interface AccountSettingsRendererProps { diff --git a/apps/sim/components/settings/lazy-section.tsx b/apps/sim/components/settings/lazy-section.tsx new file mode 100644 index 00000000000..d03227c31b7 --- /dev/null +++ b/apps/sim/components/settings/lazy-section.tsx @@ -0,0 +1,22 @@ +'use client' + +import { SettingsSectionSkeleton } from '@/components/settings/settings-section-skeleton' + +/** + * The `next/dynamic` options every code-split settings section is loaded with. + * + * Settings is a long tail of surfaces where any one visit opens exactly one of them, so + * bundling them all would charge every visitor for the whole tail. The `loading` fallback is + * what keeps that split invisible: it is the same placeholder the route's `loading.tsx` + * renders, so a section whose chunk was not warmed by a sidebar hover still transitions + * skeleton → content instead of blanking in between. + * + * Every plane's renderer passes this, so the wait looks identical whether a section is + * reached inside a workspace, an account, an organization, or a self-hosted deployment. + * + * Shared as options rather than as a `dynamic()` wrapper on purpose — a wrapper has to + * re-infer each section's props through its own generic, and loses them. + */ +export const SETTINGS_SECTION_LOADING_OPTIONS = { + loading: () => , +} diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index f70cc4193d1..042ca3ead0e 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -27,6 +27,7 @@ import { } from '@sim/emcn/icons' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { CodeIcon, McpIcon } from '@/components/icons' +import type { SettingsHeaderMeta } from '@/components/settings/settings-header' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, @@ -1066,6 +1067,32 @@ export function resolveWorkspaceNavigation({ }) } +/** + * The routed section's static header identity for a standalone plane, or `null` when the + * segment names no known section. The workspace plane resolves its own equivalent from its + * catalog in `settings/navigation.ts`. + */ +export function getSettingsHeaderMeta( + plane: SettingsPlane, + section: string +): SettingsHeaderMeta | null { + const item = getSettingsSectionMeta(plane, section) + return item ? toSettingsHeaderMeta(item) : null +} + +/** + * Adapts a navigation entry to the header shell's static identity. + * + * The catalog calls it `label` because it names a sidebar row; the shell calls it `title` + * because it renders a heading. One adapter keeps every plane's shell fed from the catalog + * instead of each one restating the mapping. + */ +export function toSettingsHeaderMeta( + item: Pick +): SettingsHeaderMeta { + return { title: item.label, description: item.description, docsLink: item.docsLink } +} + export function getSettingsSectionMeta( plane: SettingsPlane, section: string diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index 66ae7a1efc3..36ac5fa2279 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -3,40 +3,57 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' +import { SETTINGS_SECTION_LOADING_OPTIONS } from '@/components/settings/lazy-section' import type { OrganizationSettingsSection } from '@/components/settings/navigation' import { captureEvent } from '@/lib/posthog/client' -const TeamManagement = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( - (module) => module.TeamManagement - ) +const TeamManagement = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/team-management/team-management' + ).then((module) => module.TeamManagement), + SETTINGS_SECTION_LOADING_OPTIONS ) -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( - (module) => module.Billing - ) +const Billing = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( + (module) => module.Billing + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const AccessControl = dynamic(() => - import('@/ee/access-control/components/access-control').then((module) => module.AccessControl) +const AccessControl = dynamic( + () => + import('@/ee/access-control/components/access-control').then((module) => module.AccessControl), + SETTINGS_SECTION_LOADING_OPTIONS ) -const AuditLogs = dynamic(() => - import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) +const AuditLogs = dynamic( + () => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs), + SETTINGS_SECTION_LOADING_OPTIONS ) -const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) -const SessionPolicySettings = dynamic(() => - import('@/ee/session-policy/components/session-policy-settings').then( - (module) => module.SessionPolicySettings - ) +const SSO = dynamic( + () => import('@/ee/sso/components/sso-settings').then((module) => module.SSO), + SETTINGS_SECTION_LOADING_OPTIONS ) -const DataRetentionSettings = dynamic(() => - import('@/ee/data-retention/components/data-retention-settings').then( - (module) => module.DataRetentionSettings - ) +const SessionPolicySettings = dynamic( + () => + import('@/ee/session-policy/components/session-policy-settings').then( + (module) => module.SessionPolicySettings + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const DataDrainsSettings = dynamic(() => - import('@/ee/data-drains/components/data-drains-settings').then( - (module) => module.DataDrainsSettings - ) +const DataRetentionSettings = dynamic( + () => + import('@/ee/data-retention/components/data-retention-settings').then( + (module) => module.DataRetentionSettings + ), + SETTINGS_SECTION_LOADING_OPTIONS +) +const DataDrainsSettings = dynamic( + () => + import('@/ee/data-drains/components/data-drains-settings').then( + (module) => module.DataDrainsSettings + ), + SETTINGS_SECTION_LOADING_OPTIONS ) const WhitelabelingSettings = dynamic( () => diff --git a/apps/sim/components/settings/selfhost-settings-renderer.tsx b/apps/sim/components/settings/selfhost-settings-renderer.tsx index 3258c37ebb7..fbc8728b3a1 100644 --- a/apps/sim/components/settings/selfhost-settings-renderer.tsx +++ b/apps/sim/components/settings/selfhost-settings-renderer.tsx @@ -3,19 +3,24 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' +import { SETTINGS_SECTION_LOADING_OPTIONS } from '@/components/settings/lazy-section' import type { SelfHostSettingsSection } from '@/components/settings/navigation' import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( - (module) => module.Billing - ) +const Billing = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( + (module) => module.Billing + ), + SETTINGS_SECTION_LOADING_OPTIONS ) -const ChatKeys = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then( - (module) => module.Copilot - ) +const ChatKeys = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then( + (module) => module.Copilot + ), + SETTINGS_SECTION_LOADING_OPTIONS ) interface SelfHostSettingsRendererProps { diff --git a/apps/sim/components/settings/settings-header-shell.test.tsx b/apps/sim/components/settings/settings-header-shell.test.tsx index bca2da37694..6c0f57e85f7 100644 --- a/apps/sim/components/settings/settings-header-shell.test.tsx +++ b/apps/sim/components/settings/settings-header-shell.test.tsx @@ -114,3 +114,67 @@ describe('SettingsHeaderShell action routing', () => { expect(onSave).not.toHaveBeenCalled() }) }) + +describe('SettingsHeaderShell static meta', () => { + const META = { title: 'Secrets', description: 'Workspace credentials.' } + + function heading(): string | null { + return container.querySelector('h1')?.textContent?.trim() ?? null + } + + function paragraph(): string | null { + return container.querySelector('p')?.textContent?.trim() ?? null + } + + function renderWithMeta(body: React.ReactNode) { + act(() => { + root.render( + + {body} + + ) + }) + } + + it('renders the routed section title before any body has registered one', () => { + renderWithMeta(
) + + expect(heading()).toBe('Secrets') + expect(paragraph()).toBe('Workspace credentials.') + }) + + it('yields to a body that registers its own header', () => { + renderWithMeta( + +
+ + ) + + expect(heading()).toBe('Add secret') + expect(paragraph()).toBe('One value.') + }) + + it('keeps the meta description out of a body that registered a title without one', () => { + renderWithMeta( + +
+ + ) + + expect(heading()).toBe('Add secret') + expect(paragraph()).toBeNull() + }) + + it('falls back to the meta title when the body unmounts mid-navigation', () => { + renderWithMeta( + +
+ + ) + expect(heading()).toBe('Add secret') + + renderWithMeta(
) + + expect(heading()).toBe('Secrets') + }) +}) diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 1761f574679..ea35c7d7fed 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -66,6 +66,18 @@ export interface SettingsHeaderConfig { scrollContainerRef?: Ref } +/** + * A section's static header identity, derivable from its navigation entry alone. + * + * The shell renders this until the section body mounts and registers a live config, so the + * heading paints in the route's first frame instead of arriving a chunk fetch later. + */ +export interface SettingsHeaderMeta { + title: string + description?: string + docsLink?: string +} + const EMPTY_CONFIG: SettingsHeaderConfig = {} const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null) @@ -233,10 +245,27 @@ export function orderHeaderActions( .sort((a, b) => rank(a.action) - rank(b.action)) } -export function SettingsHeaderShell({ children }: { children: ReactNode }) { +interface SettingsHeaderShellProps { + /** + * The routed section's static header identity. Owns the heading whenever no section body + * has registered one — on the route's first frame, while a lazily-loaded section chunk is + * still in flight, and across the gap where an outgoing body has already reset the config. + * Without it the heading blanks and re-fills on every section switch. + */ + meta?: SettingsHeaderMeta | null + children: ReactNode +} + +export function SettingsHeaderShell({ meta, children }: SettingsHeaderShellProps) { const read = useContext(ReadContext) const configRef = read?.configRef - const config = configRef?.current ?? EMPTY_CONFIG + const registered = configRef?.current ?? EMPTY_CONFIG + /** + * Substituted wholesale rather than field-by-field: a section that registers an explicit + * `title` and no `description` is deliberately suppressing the meta description, so the + * two must never be merged. + */ + const config: SettingsHeaderConfig = registered === EMPTY_CONFIG && meta ? meta : registered const { title, description, docsLink, back, actions, search, scrollContainerRef } = config return ( diff --git a/apps/sim/components/settings/settings-section-skeleton.tsx b/apps/sim/components/settings/settings-section-skeleton.tsx new file mode 100644 index 00000000000..870bb907584 --- /dev/null +++ b/apps/sim/components/settings/settings-section-skeleton.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from '@sim/emcn' + +/** + * Row count is deliberately generic. Sections differ too much for a faithful per-section + * skeleton, and the header above this — title, description, docs link — is already real, + * resolved from the routed section's navigation entry before the body exists. + */ +const PLACEHOLDER_ROWS = [0, 1, 2, 3] + +/** + * The one body placeholder for a settings section that is not on screen yet. + * + * Rendered by a route's `loading.tsx` while its access gate resolves, and again by + * {@link SETTINGS_SECTION_LOADING_OPTIONS} while the section's chunk is in flight. Sharing a single + * component across both is what makes those two waits read as one continuous state rather + * than skeleton → blank → content. + */ +export function SettingsSectionSkeleton() { + return ( +
+ {PLACEHOLDER_ROWS.map((row) => ( + + ))} +
+ ) +} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index 82637e414d5..56818fe0ede 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -60,6 +60,12 @@ function SidebarTooltip({ ) } +/** + * Rows are buttons rather than links so leaving a dirty section runs the unsaved-changes + * guard first. That costs them Next's automatic viewport prefetch, so the route payload is + * warmed explicitly on hover and focus instead — without it every section switch pays a + * cold server round-trip at click time. + */ export function SettingsSidebar
({ activeSection, plane, @@ -159,6 +165,8 @@ export function SettingsSidebar
({