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
11 changes: 3 additions & 8 deletions apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
Expand Down Expand Up @@ -58,14 +57,10 @@ export default async function AccountSettingsSectionPage({
}

/**
* Sections read URL query params via nuqs, so the renderer must sit under a
* Suspense boundary. The null fallback preserves the existing chunk-loading UI.
* No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
* under every switch (see the workspace section layout).
*/
const content = (
<Suspense fallback={null}>
<AccountSettingsRenderer section={parsed} />
</Suspense>
)
const content = <AccountSettingsRenderer section={parsed} />

if (parsed === 'general') {
const queryClient = getQueryClient()
Expand Down

This file was deleted.

10 changes: 5 additions & 5 deletions apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
Expand Down Expand Up @@ -82,12 +81,13 @@ export default async function OrganizationSettingsSectionPage({
return <OrganizationSettings section={resolved.section} />
}

/** Account sections read URL params via nuqs, so the renderer sits under a boundary; nothing stands in for it. */
/**
* No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
* under every switch (see the workspace section layout).
*/
const content = (
<SettingsSectionProvider plane='account' section={resolved.section}>
<Suspense fallback={null}>
<AccountSettingsRenderer section={resolved.section} />
</Suspense>
<AccountSettingsRenderer section={resolved.section} />
</SettingsSectionProvider>
)

Expand Down
11 changes: 3 additions & 8 deletions apps/sim/app/selfhost/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
Expand Down Expand Up @@ -48,14 +47,10 @@ export default async function SelfHostSettingsSectionPage({
if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general'))

/**
* Sections read URL query params via nuqs, so the renderer must sit under a
* Suspense boundary. The null fallback preserves the existing chunk-loading UI.
* No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
* under every switch (see the workspace section layout).
*/
const content = (
<Suspense fallback={null}>
<SelfHostSettingsRenderer section={parsed} />
</Suspense>
)
const content = <SelfHostSettingsRenderer section={parsed} />
Comment thread
waleedlatif1 marked this conversation as resolved.

if (parsed === 'general') {
const queryClient = getQueryClient()
Expand Down
21 changes: 14 additions & 7 deletions apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => stri
* The heading is resolved here rather than pushed up from the section body, so it renders with
* the shell instead of waiting on the body's lazily-loaded chunk.
*
* Whether a segment names a section at all is decided here too, above the sibling
* `loading.tsx`. Inside that Suspense boundary a `notFound()` or `redirect()` can no longer set
* the response status — React replays the boundary on the client and the shell still flushes
* 200 — so a bad or legacy URL loaded directly would answer 200 and redirect in a second round
* trip. Deciding it above the boundary keeps the 404 and the 307. Whether the *viewer* may open
* a section is a different question and stays in the page, where it belongs; those checks need
* the database and are reached almost entirely by client navigation.
* Whether a segment names a section at all is decided here too, before the shell renders, so a
* bad or legacy URL loaded directly answers 404 or 307 without touching the database. Whether
* the *viewer* may open a section is a different question and stays in the page, where it
* belongs; those checks need the database and are reached almost entirely by client navigation.
*
* There is deliberately no sibling `loading.tsx`, and the page renders its body under no Suspense
* boundary of its own. React holds content that resolves inside a freshly committed fallback for
* at least 300ms, so any boundary mounted with the section — a route fallback, or a page-level
* `<Suspense>` around the code-split body — put that floor under every section switch. Without
* one, a switch is a transition that keeps the outgoing section on screen until the incoming one
* and its chunk are ready (immediately, when the sidebar's intent prefetch has landed), and the
* sidebar moves its selection on click so the click still reads as acknowledged. A boundary above
* this layout (a `settings/loading.tsx`, or a `<Suspense>` in the settings layout) would also
* swallow the 404 and 307 above.
*
* Authentication is already enforced by the ancestor workspace layout, so this runs only for a
* signed-in viewer.
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ export default async function WorkspaceSettingsSectionPage({

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={null}>
<SettingsPage section={parsed} />
</Suspense>
<SettingsPage section={parsed} />
</HydrationBoundary>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react'
import { Plus, Wrench } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import dynamic from 'next/dynamic'
Comment thread
waleedlatif1 marked this conversation as resolved.
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
Expand All @@ -11,7 +12,6 @@ import {
customToolIdParam,
customToolIdUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
Expand All @@ -22,6 +22,20 @@ import {
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { useCustomTools } from '@/hooks/queries/custom-tools'

/**
* The editor reaches the code-generation and tag-dropdown tooling, which pulls in the block
* registry. Loaded on open (or on Add-chip intent), so the list does not download it. `loading`
* gives it its own boundary: opening is a plain state update, and the section page has no
* boundary of its own.
*/
const loadCustomToolDetail = () =>
import(
'@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail'
)
const CustomToolDetail = dynamic(() => loadCustomToolDetail().then((m) => m.CustomToolDetail), {
loading: () => null,
})

export function CustomTools() {
const params = useParams()
const workspaceId = params.workspaceId as string
Expand Down Expand Up @@ -68,6 +82,7 @@ export function CustomTools() {
icon: Plus,
variant: 'primary',
onSelect: () => setIsCreating(true),
onPrefetch: () => void loadCustomToolDetail(),
disabled: isLoading,
},
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ import {
useUserProfile,
} from '@/hooks/queries/user-profile'

const AuthorizedApps = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then(
(module) => module.AuthorizedApps
)
/** `loading` gives the view its own boundary; the section page has none to suspend into. */
const AuthorizedApps = dynamic(
() =>
import(
'@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps'
).then((module) => module.AuthorizedApps),
{ loading: () => null }
)

const logger = createLogger('General')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,47 @@
import type { QueryClient } from '@tanstack/react-query'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { forkLineageQueryOptions } from '@/ee/workspace-forking/hooks/workspace-fork'
import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list'
import { customToolsQueryOptions } from '@/hooks/queries/custom-tools'
import {
personalEnvironmentQueryOptions,
workspaceEnvironmentQueryOptions,
} from '@/hooks/queries/environment'
import { pendingInvitationsQueryOptions } from '@/hooks/queries/invitations'
import { mcpServersQueryOptions } from '@/hooks/queries/mcp'
import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data'
import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials'
import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent'
import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers'

/**
* Each section's first-content queries, started on navigation intent so they load alongside the
* route payload instead of after the section mounts.
*/
const SETTINGS_QUERY_WARMERS: Partial<
Record<SettingsSection, (queryClient: QueryClient, context: SettingsQueryWarmContext) => void>
> = {
secrets: (queryClient, { workspaceId }) =>
secrets: (queryClient, { workspaceId }) => {
prefetchQueryOnIntent(queryClient, personalEnvironmentQueryOptions())
prefetchQueryOnIntent(queryClient, workspaceEnvironmentQueryOptions(workspaceId))
prefetchQueryOnIntent(
queryClient,
workspaceCredentialListQueryOptions(workspaceId, 'env_workspace')
),
)
},
forks: (queryClient, { workspaceId }) =>
prefetchQueryOnIntent(queryClient, forkLineageQueryOptions(workspaceId)),
teammates: (queryClient, { workspaceId }) =>
prefetchQueryOnIntent(queryClient, pendingInvitationsQueryOptions(workspaceId)),
apikeys: (queryClient, { workspaceId }) =>
prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')),
'custom-tools': (queryClient, { workspaceId }) =>
prefetchQueryOnIntent(queryClient, customToolsQueryOptions(workspaceId)),
mcp: (queryClient, { workspaceId }) =>
prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)),
'workflow-mcp-servers': (queryClient, { workspaceId }) =>
prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)),
billing: (queryClient, { billingOrganizationId }) => {
if (billingOrganizationId) {
prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ORGANIZATION_PLANE_UNIFIED_SECTIONS,
} from '@/components/settings/navigation'
import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions'
Expand Down Expand Up @@ -294,10 +295,11 @@ export function SettingsSidebar({

const segments = pathname?.split('/') ?? []
const settingsIndex = segments.indexOf('settings')
const activeSection: SettingsSection =
const routeSection: SettingsSection =
settingsIndex !== -1 && segments[settingsIndex + 1]
? (segments[settingsIndex + 1] as SettingsSection)
: 'general'
const { activeSection, navigateToSection } = usePendingSettingsSelection(routeSection)

const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()

Expand Down Expand Up @@ -488,9 +490,12 @@ export function SettingsSidebar({
event.preventDefault()
return
}
if (!useSettingsDirtyStore.getState().isDirty) return
event.preventDefault()
requestLeave(() => router.replace(href, { scroll: false }))
if (!useSettingsDirtyStore.getState().isDirty) {
navigateToSection(section, href)
return
}
requestLeave(() => navigateToSection(section, href))
}}
>
{content}
Expand Down
14 changes: 10 additions & 4 deletions apps/sim/components/settings/settings-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type StandaloneSettingsPlane,
} from '@/components/settings/navigation'
import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section'
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
Expand Down Expand Up @@ -91,6 +92,8 @@ export function SettingsSidebar<Section extends SettingsSection>({
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const { activeSection: selectedSection, navigateToSection } =
usePendingSettingsSelection(activeSection)

const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave)
Expand Down Expand Up @@ -167,7 +170,7 @@ export function SettingsSidebar<Section extends SettingsSection>({
<div className={cn(SIDEBAR_ITEM_GAP_CLASS, 'flex flex-col px-2')}>
{group.items.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
const active = selectedSection === item.id
Comment thread
waleedlatif1 marked this conversation as resolved.
const href = hrefForSection(item.id)
return (
<SidebarTooltip
Expand All @@ -192,10 +195,13 @@ export function SettingsSidebar<Section extends SettingsSection>({
event.preventDefault()
return
}
const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()
if (!isDirty && !navigationBlocked) return
event.preventDefault()
requestLeave(() => router.replace(href, { scroll: false }))
const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState()
if (!isDirty && !navigationBlocked) {
navigateToSection(item.id, href)
return
}
requestLeave(() => navigateToSection(item.id, href))
}}
>
<Icon className={chipContentIconClass} />
Expand Down
35 changes: 35 additions & 0 deletions apps/sim/components/settings/use-pending-settings-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client'

import { useCallback, useOptimistic, useTransition } from 'react'
import { useRouter } from 'next/navigation'

/**
* Settings sidebar navigation whose clicked row shows as selected until the route settles.
*
* Section routes have no loading boundary (see the workspace section layout), so a navigation
* keeps the outgoing section on screen until the incoming one is ready. Moving the selection on
* click keeps the click acknowledged while the route resolves.
*
* The selection is optimistic state set inside the navigation's own transition, so React drops it
* the moment that transition settles — on commit, and equally when the server redirects back to
* the current section or the navigation fails. It never outlives the navigation that set it.
*/
export function usePendingSettingsSelection<TSection extends string>(
routeSection: TSection
): { activeSection: TSection; navigateToSection: (section: TSection, href: string) => void } {
const router = useRouter()
const [activeSection, setOptimisticSection] = useOptimistic(routeSection)
const [, startTransition] = useTransition()

const navigateToSection = useCallback(
(section: TSection, href: string) => {
startTransition(() => {
setOptimisticSection(section)
router.replace(href, { scroll: false })
})
},
[router, setOptimisticSection]
)

return { activeSection, navigateToSection }
}
Loading
Loading