Skip to content

Commit c82d618

Browse files
authored
improvement(settings): remove the 300ms floor under settings section switches (#8435)
* improvement(settings): remove the 300ms floor under settings section switches Every section switch mounted a fresh Suspense boundary (the empty section loading.tsx and a page-level <Suspense fallback={null}> around the code-split body). React 19 holds content that resolves into a just-committed fallback for at least 300ms, so every switch paid that before the section even rendered or started its queries. - drop the section loading boundaries and page-level Suspense on all settings planes; navigations are transitions, so the outgoing section stays until the incoming one is ready - move the sidebar selection on click so the click still reads as acknowledged - warm each hot section's first-content queries on navigation intent - load the fork sync editor and custom tool editor on open; they pulled the block and trigger registries into the list chunks * fix(settings): tie the pending sidebar selection to its navigation The pending row cleared only on a pathname change, so a navigation the server redirected back to the current section, or one that failed, left the clicked row selected and its click guard swallowing retries. Set the selection optimistically inside the navigation's own transition so React drops it when that transition settles.
1 parent d3f2830 commit c82d618

20 files changed

Lines changed: 430 additions & 291 deletions

File tree

‎apps/sim/app/account/settings/[section]/page.tsx‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Suspense } from 'react'
21
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
32
import type { Metadata } from 'next'
43
import { notFound, redirect } from 'next/navigation'
@@ -58,14 +57,10 @@ export default async function AccountSettingsSectionPage({
5857
}
5958

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

7065
if (parsed === 'general') {
7166
const queryClient = getQueryClient()

‎apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎apps/sim/app/o/[organizationId]/settings/[section]/page.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Suspense } from 'react'
21
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
32
import type { Metadata } from 'next'
43
import { notFound, redirect } from 'next/navigation'
@@ -82,12 +81,13 @@ export default async function OrganizationSettingsSectionPage({
8281
return <OrganizationSettings section={resolved.section} />
8382
}
8483

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

‎apps/sim/app/selfhost/settings/[section]/page.tsx‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Suspense } from 'react'
21
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
32
import type { Metadata } from 'next'
43
import { notFound, redirect } from 'next/navigation'
@@ -48,14 +47,10 @@ export default async function SelfHostSettingsSectionPage({
4847
if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general'))
4948

5049
/**
51-
* Sections read URL query params via nuqs, so the renderer must sit under a
52-
* Suspense boundary. The null fallback preserves the existing chunk-loading UI.
50+
* No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
51+
* under every switch (see the workspace section layout).
5352
*/
54-
const content = (
55-
<Suspense fallback={null}>
56-
<SelfHostSettingsRenderer section={parsed} />
57-
</Suspense>
58-
)
53+
const content = <SelfHostSettingsRenderer section={parsed} />
5954

6055
if (parsed === 'general') {
6156
const queryClient = getQueryClient()

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

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

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

Lines changed: 0 additions & 19 deletions
This file was deleted.

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,7 @@ export default async function WorkspaceSettingsSectionPage({
111111

112112
return (
113113
<HydrationBoundary state={dehydrate(queryClient)}>
114-
<Suspense fallback={null}>
115-
<SettingsPage section={parsed} />
116-
</Suspense>
114+
<SettingsPage section={parsed} />
117115
</HydrationBoundary>
118116
)
119117
}

‎apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react'
44
import { Plus, Wrench } from '@sim/emcn/icons'
55
import { getErrorMessage } from '@sim/utils/errors'
6+
import dynamic from 'next/dynamic'
67
import { useParams } from 'next/navigation'
78
import { useQueryState } from 'nuqs'
89
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
@@ -11,7 +12,6 @@ import {
1112
customToolIdParam,
1213
customToolIdUrlKeys,
1314
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
14-
import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail'
1515
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1616
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
1717
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -22,6 +22,20 @@ import {
2222
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
2323
import { useCustomTools } from '@/hooks/queries/custom-tools'
2424

25+
/**
26+
* The editor reaches the code-generation and tag-dropdown tooling, which pulls in the block
27+
* registry. Loaded on open (or on Add-chip intent), so the list does not download it. `loading`
28+
* gives it its own boundary: opening is a plain state update, and the section page has no
29+
* boundary of its own.
30+
*/
31+
const loadCustomToolDetail = () =>
32+
import(
33+
'@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail'
34+
)
35+
const CustomToolDetail = dynamic(() => loadCustomToolDetail().then((m) => m.CustomToolDetail), {
36+
loading: () => null,
37+
})
38+
2539
export function CustomTools() {
2640
const params = useParams()
2741
const workspaceId = params.workspaceId as string
@@ -68,6 +82,7 @@ export function CustomTools() {
6882
icon: Plus,
6983
variant: 'primary',
7084
onSelect: () => setIsCreating(true),
85+
onPrefetch: () => void loadCustomToolDetail(),
7186
disabled: isLoading,
7287
},
7388
]

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,13 @@ import {
5151
useUserProfile,
5252
} from '@/hooks/queries/user-profile'
5353

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

6063
const logger = createLogger('General')

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,47 @@
11
import type { QueryClient } from '@tanstack/react-query'
22
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
3+
import { forkLineageQueryOptions } from '@/ee/workspace-forking/hooks/workspace-fork'
4+
import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list'
5+
import { customToolsQueryOptions } from '@/hooks/queries/custom-tools'
6+
import {
7+
personalEnvironmentQueryOptions,
8+
workspaceEnvironmentQueryOptions,
9+
} from '@/hooks/queries/environment'
10+
import { pendingInvitationsQueryOptions } from '@/hooks/queries/invitations'
11+
import { mcpServersQueryOptions } from '@/hooks/queries/mcp'
312
import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
413
import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data'
514
import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials'
615
import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent'
16+
import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers'
717

18+
/**
19+
* Each section's first-content queries, started on navigation intent so they load alongside the
20+
* route payload instead of after the section mounts.
21+
*/
822
const SETTINGS_QUERY_WARMERS: Partial<
923
Record<SettingsSection, (queryClient: QueryClient, context: SettingsQueryWarmContext) => void>
1024
> = {
11-
secrets: (queryClient, { workspaceId }) =>
25+
secrets: (queryClient, { workspaceId }) => {
26+
prefetchQueryOnIntent(queryClient, personalEnvironmentQueryOptions())
27+
prefetchQueryOnIntent(queryClient, workspaceEnvironmentQueryOptions(workspaceId))
1228
prefetchQueryOnIntent(
1329
queryClient,
1430
workspaceCredentialListQueryOptions(workspaceId, 'env_workspace')
15-
),
31+
)
32+
},
33+
forks: (queryClient, { workspaceId }) =>
34+
prefetchQueryOnIntent(queryClient, forkLineageQueryOptions(workspaceId)),
35+
teammates: (queryClient, { workspaceId }) =>
36+
prefetchQueryOnIntent(queryClient, pendingInvitationsQueryOptions(workspaceId)),
37+
apikeys: (queryClient, { workspaceId }) =>
38+
prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')),
39+
'custom-tools': (queryClient, { workspaceId }) =>
40+
prefetchQueryOnIntent(queryClient, customToolsQueryOptions(workspaceId)),
41+
mcp: (queryClient, { workspaceId }) =>
42+
prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)),
43+
'workflow-mcp-servers': (queryClient, { workspaceId }) =>
44+
prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)),
1645
billing: (queryClient, { billingOrganizationId }) => {
1746
if (billingOrganizationId) {
1847
prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId))

0 commit comments

Comments
 (0)