Skip to content

Commit bc9a85f

Browse files
committed
improvement(settings): paint settings section clicks immediately and cut a round trip
- Share the in-flight section navigation between the sidebar and the content area: the clicked section's heading paints over an empty body while its route resolves, with the outgoing section kept laid out but invisible and inert. It is optimistic state inside the navigation's own transition, not a Suspense fallback, so nothing is held back and a redirect back restores the section untouched. - A press warms data and code but no longer prefetches the route: a prefetch started at mousedown made the navigation wait on a second, two-step request instead of its own. - Warm every section's chunk on intent from the persistent settings layout instead of the sidebar, so the chunk loads alongside the route payload instead of after it, without growing the workspace chrome's module graph. - Seed fork availability for admins and read the server-seeded viewer permission, so Workspace Forks renders with the rest of the sidebar instead of after a session fetch and an availability fetch.
1 parent 3841b94 commit bc9a85f

18 files changed

Lines changed: 508 additions & 311 deletions

File tree

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
4+
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
45
import { getSession } from '@/lib/auth'
56
import { getActiveOrganizationId } from '@/lib/auth/session-response'
67
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
@@ -81,12 +82,14 @@ export default async function OrganizationLayout({
8182
<div className='workspace-root flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
8283
<ImpersonationBanner />
8384
<SessionExpired />
84-
<WorkspaceChrome
85-
sidebar={<OrganizationSidebar />}
86-
initialSidebarCollapsed={initialSidebarCollapsed}
87-
>
88-
{children}
89-
</WorkspaceChrome>
85+
<SettingsNavigationProvider>
86+
<WorkspaceChrome
87+
sidebar={<OrganizationSidebar />}
88+
initialSidebarCollapsed={initialSidebarCollapsed}
89+
>
90+
{children}
91+
</WorkspaceChrome>
92+
</SettingsNavigationProvider>
9093
</div>
9194
</GlobalCommandsProvider>
9295
</OrganizationProvider>
Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import type { ReactNode } from 'react'
22
import { notFound, redirect } from 'next/navigation'
3-
import {
4-
getSettingsSectionMeta,
5-
ORGANIZATION_SETTINGS_ITEMS,
6-
toSettingsHeaderMeta,
7-
} from '@/components/settings/navigation'
83
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
94
import { organizationRoutes } from '@/lib/navigation/paths'
10-
import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
5+
import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'
116

127
interface OrganizationSettingsSectionLayoutProps {
138
children: ReactNode
@@ -24,18 +19,12 @@ export default async function OrganizationSettingsSectionLayout({
2419
`${organizationRoutes(organizationId).settingsSection('general')}?view=authorized-apps`
2520
)
2621
}
27-
const resolved = resolveOrganizationSurfaceSection(section)
28-
const meta =
29-
resolved?.plane === 'organization'
30-
? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section)
31-
: resolved
32-
? getSettingsSectionMeta('account', resolved.section)
33-
: null
22+
const meta = resolveOrganizationSurfaceHeaderMeta(section)
3423
if (!meta) notFound()
3524

3625
return (
3726
<SettingsHeaderProvider>
38-
<SettingsHeaderShell meta={toSettingsHeaderMeta(meta)}>{children}</SettingsHeaderShell>
27+
<SettingsHeaderShell meta={meta}>{children}</SettingsHeaderShell>
3928
</SettingsHeaderProvider>
4029
)
4130
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4+
import { SettingsPendingSection } from '@/components/settings/settings-navigation-provider'
45
import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
6+
import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'
57

68
interface OrganizationSettingsLayoutProps {
79
children: ReactNode
810
}
911

1012
export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) {
1113
useSettingsBeforeUnload()
12-
return <div className='flex h-full flex-col bg-[var(--bg)]'>{children}</div>
14+
return (
15+
<div className='flex h-full flex-col bg-[var(--bg)]'>
16+
<SettingsPendingSection resolveMeta={resolveOrganizationSurfaceHeaderMeta}>
17+
{children}
18+
</SettingsPendingSection>
19+
</div>
20+
)
1321
}

‎apps/sim/app/o/[organizationId]/settings/navigation.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
ACCOUNT_SETTINGS_ITEMS,
33
type AccountSettingsSection,
4+
getSettingsSectionMeta,
45
isOrganizationSettingsSectionAvailable,
56
ORGANIZATION_SETTINGS_GROUPS,
67
ORGANIZATION_SETTINGS_ITEMS,
@@ -9,7 +10,9 @@ import {
910
parseSettingsPathSection,
1011
resolveOrganizationSectionAccess,
1112
type SettingsNavigationItem,
13+
toSettingsHeaderMeta,
1214
} from '@/components/settings/navigation'
15+
import type { SettingsHeaderMeta } from '@/components/settings/settings-header'
1316

1417
/**
1518
* A section on the organization surface's settings, tagged with the plane that
@@ -64,6 +67,18 @@ export function resolveOrganizationSurfaceSection(
6467
return account ? { plane: 'account', section: account } : null
6568
}
6669

70+
/** The heading an organization-surface section path renders with, or null for an unknown path. */
71+
export function resolveOrganizationSurfaceHeaderMeta(path: string): SettingsHeaderMeta | null {
72+
const resolved = resolveOrganizationSurfaceSection(path)
73+
const item =
74+
resolved?.plane === 'organization'
75+
? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section)
76+
: resolved
77+
? getSettingsSectionMeta('account', resolved.section)
78+
: null
79+
return item ? toSettingsHeaderMeta(item) : null
80+
}
81+
6782
export function organizationSettingsNavigation(
6883
isAdmin: boolean,
6984
features: OrganizationSettingsFeatures,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ const {
1313
mockPrefetchWorkspaceHostContext,
1414
mockPrefetchWorkspaceSidebar,
1515
mockPrefetchWorkspaceAccess,
16+
mockPrefetchWorkspaceForkAvailability,
1617
} = vi.hoisted(() => ({
1718
mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children),
1819
mockGetOrgWhitelabelSettings: vi.fn(),
1920
mockPrefetchWorkspaceHostContext: vi.fn(),
2021
mockPrefetchWorkspaceSidebar: vi.fn(),
2122
mockPrefetchWorkspaceAccess: vi.fn(),
23+
mockPrefetchWorkspaceForkAvailability: vi.fn(),
2224
}))
2325

2426
vi.mock('@sim/emcn', () => emcnMock)
@@ -38,6 +40,7 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({
3840
vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({
3941
prefetchWorkspaceHostContext: mockPrefetchWorkspaceHostContext,
4042
prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar,
43+
prefetchWorkspaceForkAvailability: mockPrefetchWorkspaceForkAvailability,
4144
}))
4245

4346
vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({
@@ -149,6 +152,7 @@ describe('WorkspaceLayout host context', () => {
149152
mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT)
150153
mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined)
151154
mockPrefetchWorkspaceAccess.mockResolvedValue(undefined)
155+
mockPrefetchWorkspaceForkAvailability.mockResolvedValue(undefined)
152156
mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' })
153157
})
154158

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
4+
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
45
import { getSession } from '@/lib/auth'
56
import { getActiveOrganizationId } from '@/lib/auth/session-response'
67
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
@@ -12,6 +13,7 @@ import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session
1213
import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
1314
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
1415
import {
16+
prefetchWorkspaceForkAvailability,
1517
prefetchWorkspaceHostContext,
1618
prefetchWorkspaceSidebar,
1719
} from '@/app/workspace/[workspaceId]/prefetch'
@@ -50,6 +52,11 @@ export default async function WorkspaceLayout({
5052
}
5153

5254
const activeOrganizationId = getActiveOrganizationId(session)
55+
const principal = {
56+
kind: 'session',
57+
userId: session.user.id,
58+
sessionId: session.session.id,
59+
} as const
5360
const [
5461
cookieStore,
5562
initialOrgSettings,
@@ -74,11 +81,8 @@ export default async function WorkspaceLayout({
7481
isMothershipModelSelectorEnabled(),
7582
isPlanModeEnabled(),
7683
resolveOrganizationEntryPath(session),
77-
prefetchWorkspaceAccess(queryClient, workspaceId, {
78-
kind: 'session',
79-
userId: session.user.id,
80-
sessionId: session.session.id,
81-
}),
84+
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
85+
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
8286
])
8387
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
8488

@@ -108,12 +112,14 @@ export default async function WorkspaceLayout({
108112
<SessionExpired />
109113
<WorkspacePermissionsProvider>
110114
<WorkspaceScopeSync />
111-
<WorkspaceChrome
112-
sidebar={<Sidebar organizationHref={organizationHref} />}
113-
initialSidebarCollapsed={initialSidebarCollapsed}
114-
>
115-
{children}
116-
</WorkspaceChrome>
115+
<SettingsNavigationProvider>
116+
<WorkspaceChrome
117+
sidebar={<Sidebar organizationHref={organizationHref} />}
118+
initialSidebarCollapsed={initialSidebarCollapsed}
119+
>
120+
{children}
121+
</WorkspaceChrome>
122+
</SettingsNavigationProvider>
117123
</WorkspacePermissionsProvider>
118124
</div>
119125
</GlobalCommandsProvider>

‎apps/sim/app/workspace/[workspaceId]/prefetch.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SessionPrincipal } from '@sim/auth/principal'
12
import type { QueryClient } from '@tanstack/react-query'
23
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
34
import { isChatEnabled } from '@/lib/core/config/env-flags'
@@ -8,6 +9,10 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
89
import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
910
import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list'
1011
import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders'
12+
import {
13+
FORK_AVAILABILITY_STALE_TIME,
14+
forkAvailabilityKeys,
15+
} from '@/ee/workspace-forking/hooks/use-forking-available'
1116
import {
1217
MOTHERSHIP_CHAT_LIST_STALE_TIME,
1318
mapChat,
@@ -114,3 +119,28 @@ export async function prefetchWorkspaceSidebar(
114119
seedWorkspaceList(queryClient, userId, activeOrganizationId),
115120
])
116121
}
122+
123+
/**
124+
* Seeds fork availability, which decides whether the settings sidebar lists Workspace Forks, so
125+
* the row renders with the rest of the sidebar. Only admins can read it
126+
* (`forkOperations.discover`), so it is skipped for everyone else. It runs the availability
127+
* route's own use case; a failed read stays out of hydration and the client refetches it.
128+
*/
129+
export async function prefetchWorkspaceForkAvailability(
130+
queryClient: QueryClient,
131+
workspaceId: string,
132+
principal: SessionPrincipal,
133+
hostContext: WorkspaceHostContext
134+
): Promise<void> {
135+
if (hostContext.viewer.permission !== 'admin') return
136+
await queryClient.prefetchQuery({
137+
queryKey: forkAvailabilityKeys.detail(workspaceId),
138+
queryFn: async () => {
139+
const { getWorkspaceForkAvailability } = await import(
140+
'@/ee/workspace-forking/application/discovery'
141+
)
142+
return getWorkspaceForkAvailability.execute({ principal, input: { workspaceId } })
143+
},
144+
staleTime: FORK_AVAILABILITY_STALE_TIME,
145+
})
146+
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => stri
3434
* boundary of its own. React holds content that resolves inside a freshly committed fallback for
3535
* at least 300ms, so any boundary mounted with the section — a route fallback, or a page-level
3636
* `<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
37+
* one, a switch is a transition: the sidebar moves its selection and the settings layout paints
38+
* the incoming heading over the outgoing section, which stays mounted but invisible until the
39+
* incoming section and its chunk are ready. A boundary above
4040
* this layout (a `settings/loading.tsx`, or a `<Suspense>` in the settings layout) would also
4141
* swallow the 404 and 307 above.
4242
*

0 commit comments

Comments
 (0)