From 5d87be6aed0768c16a1c86034869be86dff7cb3e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 14:48:28 -0700 Subject: [PATCH 1/2] 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 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 --- .../app/account/settings/[section]/page.tsx | 11 +- .../settings/[section]/loading.tsx | 4 - .../settings/[section]/page.tsx | 10 +- .../app/selfhost/settings/[section]/page.tsx | 11 +- .../settings/[section]/layout.tsx | 21 +- .../settings/[section]/loading.tsx | 19 -- .../[workspaceId]/settings/[section]/page.tsx | 4 +- .../components/custom-tools/custom-tools.tsx | 17 +- .../settings/components/general/general.tsx | 11 +- .../settings-query-warmers.ts | 33 ++- .../settings-sidebar/settings-sidebar.tsx | 14 +- .../components/settings/settings-sidebar.tsx | 15 +- .../use-pending-settings-selection.ts | 38 ++++ .../fork-sync-detail-view.tsx | 202 +++++++++++++++++ .../ee/workspace-forking/components/forks.tsx | 206 ++---------------- .../workspace-forking/hooks/workspace-fork.ts | 23 +- apps/sim/hooks/queries/custom-tools.ts | 24 +- apps/sim/hooks/queries/environment.ts | 40 ++-- apps/sim/hooks/queries/invitations.ts | 21 +- ...check-tool-registry-boundary.baseline.json | 4 - 20 files changed, 438 insertions(+), 290 deletions(-) delete mode 100644 apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx create mode 100644 apps/sim/components/settings/use-pending-settings-selection.ts create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index a4a551f1a89..d5cd82d5dbb 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -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' @@ -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 = ( - - - - ) + const content = if (parsed === 'general') { const queryClient = getQueryClient() diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx deleted file mode 100644 index 8c85b14beed..00000000000 --- a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx +++ /dev/null @@ -1,4 +0,0 @@ -/** Lets the section layout show its heading while authorization and content finish loading. */ -export default function OrganizationSettingsSectionLoading() { - return null -} diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx index 9da86653b73..54211800776 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx @@ -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' @@ -82,12 +81,13 @@ export default async function OrganizationSettingsSectionPage({ return } - /** 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 = ( - - - + ) diff --git a/apps/sim/app/selfhost/settings/[section]/page.tsx b/apps/sim/app/selfhost/settings/[section]/page.tsx index 933b783a082..e5b0d30a7a4 100644 --- a/apps/sim/app/selfhost/settings/[section]/page.tsx +++ b/apps/sim/app/selfhost/settings/[section]/page.tsx @@ -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' @@ -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 = ( - - - - ) + const content = if (parsed === 'general') { const queryClient = getQueryClient() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index a2965fd9ab0..de9cd125c5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -25,13 +25,20 @@ const TOP_LEVEL_REDIRECTS: Readonly 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 + * `` 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 `` 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. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx deleted file mode 100644 index 925929408ab..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Route-transition fallback for the workspace settings sections. - * - * Its job is to exist. 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; with - * one, the navigation commits immediately and the heading changes with it. It is also what - * makes the sidebar's `router.prefetch` worth anything — with no loading boundary in the - * subtree the scheduler skips the segment request entirely, and an `AUTO` prefetch caches the - * shell only as far as the nearest boundary. - * - * It renders no body of its own because the shell that owns the header, heading and scroll - * region renders above it and is already resolved by this point. That lands in the same place - * as the two neighbouring settings fallbacks — credit-usage renders its title and description - * over an empty body, `ResourceChromeFallback` renders a real header and column headers over - * zero rows — without restating chrome this route already has. - */ -export default function WorkspaceSettingsSectionLoading() { - return null -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index d845f3b6b41..0ee633ade62 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -111,9 +111,7 @@ export default async function WorkspaceSettingsSectionPage({ return ( - - - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index c79380f8966..d6ac817c5c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -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' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -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' @@ -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 @@ -68,6 +82,7 @@ export function CustomTools() { icon: Plus, variant: 'primary', onSelect: () => setIsCreating(true), + onPrefetch: () => void loadCustomToolDetail(), disabled: isLoading, }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index bc7e01d96b6..5d1f48293c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -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') diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts index cd3f68d822c..90d22fc97cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts @@ -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 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)) 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 3593e96d90b..239c1a55025 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 @@ -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' @@ -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, selectPending } = usePendingSettingsSelection(routeSection) const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() @@ -488,9 +490,15 @@ export function SettingsSidebar({ event.preventDefault() return } - if (!useSettingsDirtyStore.getState().isDirty) return + if (!useSettingsDirtyStore.getState().isDirty) { + selectPending(section) + return + } event.preventDefault() - requestLeave(() => router.replace(href, { scroll: false })) + requestLeave(() => { + selectPending(section) + router.replace(href, { scroll: false }) + }) }} > {content} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index c8e6a0810ba..fa77370b917 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -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' @@ -91,6 +92,8 @@ export function SettingsSidebar
({ const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) const router = useRouter() + const { activeSection: selectedSection, selectPending } = + usePendingSettingsSelection(activeSection) const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave) @@ -167,7 +170,7 @@ export function SettingsSidebar
({
{group.items.map((item) => { const Icon = item.icon - const active = activeSection === item.id + const active = selectedSection === item.id const href = hrefForSection(item.id) return ( ({ return } const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState() - if (!isDirty && !navigationBlocked) return + if (!isDirty && !navigationBlocked) { + selectPending(item.id) + return + } event.preventDefault() - requestLeave(() => router.replace(href, { scroll: false })) + requestLeave(() => { + selectPending(item.id) + router.replace(href, { scroll: false }) + }) }} > diff --git a/apps/sim/components/settings/use-pending-settings-selection.ts b/apps/sim/components/settings/use-pending-settings-selection.ts new file mode 100644 index 00000000000..b3ec73f1d39 --- /dev/null +++ b/apps/sim/components/settings/use-pending-settings-selection.ts @@ -0,0 +1,38 @@ +'use client' + +import { useCallback, useState } from 'react' +import { usePathname } from 'next/navigation' + +/** + * The settings row a sidebar click is navigating to, shown as selected until the route commits. + * + * 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 pending row is dropped whenever the pathname changes — on commit, and on any navigation the + * sidebar did not start, such as the browser's back button. + */ +export function usePendingSettingsSelection( + routeSection: TSection +): { activeSection: TSection; selectPending: (section: TSection) => void } { + const pathname = usePathname() + const [pending, setPending] = useState<{ section: TSection; pathname: string | null } | null>( + null + ) + + // Cleared, not just compared: returning to this pathname later (back after the commit) must + // not resurrect a settled selection. + if (pending !== null && pending.pathname !== pathname) setPending(null) + + const selectPending = useCallback( + (section: TSection) => setPending({ section, pathname }), + [pathname] + ) + + return { + activeSection: + pending !== null && pending.pathname === pathname ? pending.section : routeSection, + selectPending, + } +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx new file mode 100644 index 00000000000..89f4acc6b7b --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + forkSyncDirectionParam, + forkSyncDirectionUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import { + ARCHIVED_PREVIEW_LIMIT, + useForkSync, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' +import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' + +interface ForkSyncDetailViewProps { + title: string + workspaceId: string + /** This workspace's name — a pull overwrites it, and the copy has to say which side that is. */ + workspaceName?: string + /** The other side of the edge being synced (this workspace's parent). */ + otherWorkspaceId: string + otherWorkspaceName: string + onBack: () => void + /** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */ + actions: SettingsAction[] +} + +/** + * The parent edge's sync page (reached from the parent row): direction, deployed-workflow + * changes, per-kind mappings (each an expandable row whose status badge is the summary), + * copy resources, and blocking references, all as page sections. + * The header's Sync chip is gated until zero blockers + required mappings + reconfigure are + * complete, and always confirms the overwrite first — that confirm is the flow's one modal. + * While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded; + * Sync itself persists the effective mapping as part of the run. + */ +export function ForkSyncDetailView({ + title, + workspaceId, + workspaceName, + otherWorkspaceId, + otherWorkspaceName, + onBack, + actions, +}: ForkSyncDetailViewProps) { + // Sync direction is shareable view state: a copied link opens the same side of the sync. + const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, { + ...forkSyncDirectionParam.parser, + ...forkSyncDirectionUrlKeys, + }) + + const controller = useForkSync({ + workspaceId, + workspaceName, + otherWorkspaceId, + otherWorkspaceName, + direction, + enabled: true, + }) + + // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed + // the shared settings dirty store so a sidebar section switch confirms too. + const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) + + const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) + + // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's + // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster + // for Discard/Save until they're saved or discarded. + const panelActions: SettingsAction[] = controller.dirty + ? saveDiscardActions({ + dirty: controller.dirty, + saving: controller.saving, + onSave: controller.save, + onDiscard: controller.discard, + }) + : [ + ...actions, + { + text: controller.submitting ? 'Working...' : 'Sync', + variant: 'primary' as const, + onSelect: () => setConfirmSyncOpen(true), + disabled: controller.syncDisabled, + tooltip: controller.syncDisabled + ? controller.syncDisabledReason + : `Push to or pull from ${otherWorkspaceName}`, + }, + ] + + const targetWorkspaceName = controller.targetWorkspaceName + + return ( + <> + + guard.guardBack(() => { + void setDirection(null) + onBack() + }), + }} + title={title} + actions={panelActions} + > + void setDirection(next)} + /> + + + + + { + setConfirmSyncOpen(false) + void controller.sync() + }, + pending: controller.submitting, + pendingLabel: 'Syncing...', + }} + > + {controller.archivedWorkflowNames.length > 0 ? ( +
+

+ Will be archived in {targetWorkspaceName} (deleted in the source): +

+ {controller.archivedWorkflowNames + .slice(0, ARCHIVED_PREVIEW_LIMIT) + .map((name, index) => ( +
+ {name} +
+ ))} + {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} + {/* A dead trigger URL is only discoverable after the fact, when the external caller goes + quiet - so it belongs in the confirm, next to the other irreversible consequences. */} + {controller.triggerUrlChanges.length > 0 ? ( +
+

+ {controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '} + {targetWorkspaceName} will stop being served — anything calling{' '} + {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks until you + re-register: +

+ {controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => ( + // Naming the URL, not just its workflow: several URLs in one workflow would render + // as identical lines, and this confirm is the last point before they stop serving. +
+ {change.workflowName} + + {buildWebhookTriggerUrl(change.path)} + +
+ ))} + {controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} +
+ + ) +} diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index b25486dde76..2487b458c49 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -4,20 +4,17 @@ import { useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft, Plus, TriangleAlert } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import dynamic from 'next/dynamic' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { forkIdParam, forkIdUrlKeys, - forkSyncDirectionParam, - forkSyncDirectionUrlKeys, forkViewParam, forkViewUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' @@ -29,13 +26,7 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel' -import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' -import { - ARCHIVED_PREVIEW_LIMIT, - useForkSync, -} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' import { ForkSyncDefaultToggle } from '@/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle' import { ForkSyncedWorkflows } from '@/ee/workspace-forking/components/fork-synced-workflows/fork-synced-workflows' import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' @@ -47,7 +38,19 @@ import { } from '@/ee/workspace-forking/hooks/workspace-fork' import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' + +/** + * The parent edge's sync page carries the mapping editors, which reach the block and trigger + * registries. Loaded when opened, so the fork list does not download them; `loading` gives it + * its own boundary, since the section page has none. + */ +const ForkSyncDetailView = dynamic( + () => + import('@/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view').then( + (m) => m.ForkSyncDetailView + ), + { loading: () => null } +) /** Explains a disabled lineage action whose target workspace the viewer cannot open. */ const NO_ACCESS_TOOLTIP = "You don't have access to this workspace" @@ -83,187 +86,6 @@ function ForkListRow({ name, actions }: ForkListRowProps) { ) } -interface ForkSyncDetailViewProps { - title: string - workspaceId: string - /** This workspace's name — a pull overwrites it, and the copy has to say which side that is. */ - workspaceName?: string - /** The other side of the edge being synced (this workspace's parent). */ - otherWorkspaceId: string - otherWorkspaceName: string - onBack: () => void - /** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */ - actions: SettingsAction[] -} - -/** - * The parent edge's sync page (reached from the parent row): direction, deployed-workflow - * changes, per-kind mappings (each an expandable row whose status badge is the summary), - * copy resources, and blocking references, all as page sections. - * The header's Sync chip is gated until zero blockers + required mappings + reconfigure are - * complete, and always confirms the overwrite first — that confirm is the flow's one modal. - * While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded; - * Sync itself persists the effective mapping as part of the run. - */ -function ForkSyncDetailView({ - title, - workspaceId, - workspaceName, - otherWorkspaceId, - otherWorkspaceName, - onBack, - actions, -}: ForkSyncDetailViewProps) { - // Sync direction is shareable view state: a copied link opens the same side of the sync. - const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, { - ...forkSyncDirectionParam.parser, - ...forkSyncDirectionUrlKeys, - }) - - const controller = useForkSync({ - workspaceId, - workspaceName, - otherWorkspaceId, - otherWorkspaceName, - direction, - enabled: true, - }) - - // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed - // the shared settings dirty store so a sidebar section switch confirms too. - const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) - - const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) - - // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's - // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster - // for Discard/Save until they're saved or discarded. - const panelActions: SettingsAction[] = controller.dirty - ? saveDiscardActions({ - dirty: controller.dirty, - saving: controller.saving, - onSave: controller.save, - onDiscard: controller.discard, - }) - : [ - ...actions, - { - text: controller.submitting ? 'Working...' : 'Sync', - variant: 'primary' as const, - onSelect: () => setConfirmSyncOpen(true), - disabled: controller.syncDisabled, - tooltip: controller.syncDisabled - ? controller.syncDisabledReason - : `Push to or pull from ${otherWorkspaceName}`, - }, - ] - - const targetWorkspaceName = controller.targetWorkspaceName - - return ( - <> - - guard.guardBack(() => { - void setDirection(null) - onBack() - }), - }} - title={title} - actions={panelActions} - > - void setDirection(next)} - /> - - - - - { - setConfirmSyncOpen(false) - void controller.sync() - }, - pending: controller.submitting, - pendingLabel: 'Syncing...', - }} - > - {controller.archivedWorkflowNames.length > 0 ? ( -
-

- Will be archived in {targetWorkspaceName} (deleted in the source): -

- {controller.archivedWorkflowNames - .slice(0, ARCHIVED_PREVIEW_LIMIT) - .map((name, index) => ( -
- {name} -
- ))} - {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( -
- and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more -
- ) : null} -
- ) : null} - {/* A dead trigger URL is only discoverable after the fact, when the external caller goes - quiet - so it belongs in the confirm, next to the other irreversible consequences. */} - {controller.triggerUrlChanges.length > 0 ? ( -
-

- {controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '} - {targetWorkspaceName} will stop being served — anything calling{' '} - {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks until you - re-register: -

- {controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => ( - // Naming the URL, not just its workflow: several URLs in one workflow would render - // as identical lines, and this confirm is the last point before they stop serving. -
- {change.workflowName} - - {buildWebhookTriggerUrl(change.path)} - -
- ))} - {controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? ( -
- and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more -
- ) : null} -
- ) : null} -
- - ) -} - interface ForkActivityDetailViewProps { workspaceId: string /** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */ diff --git a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts index 21e5acda94b..d735739a760 100644 --- a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts +++ b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts @@ -1,4 +1,10 @@ -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ForkWorkspaceBody, @@ -60,13 +66,20 @@ export function useForkResources(workspaceId?: string, enabled = true) { }) } -export function useForkLineage(workspaceId?: string, enabled = true) { - return useQuery({ +export function forkLineageQueryOptions(workspaceId: string) { + return queryOptions({ queryKey: forkKeys.lineage(workspaceId), queryFn: ({ signal }) => - requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }), - enabled: Boolean(workspaceId) && enabled, + requestJson(getForkLineageContract, { params: { id: workspaceId }, signal }), staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME, + retryOnMount: true, + }) +} + +export function useForkLineage(workspaceId?: string, enabled = true) { + return useQuery({ + ...forkLineageQueryOptions(workspaceId ?? ''), + enabled: Boolean(workspaceId) && enabled, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index 0edd2e4737d..066c7136fb6 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { isRecordLike, toRecord } from '@sim/utils/object' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { deleteCustomToolContract, @@ -164,15 +170,23 @@ async function fetchCustomTools( return normalizedTools } +/** A workspace's custom tools list, shared by {@link useCustomTools} and the settings intent warmer. */ +export function customToolsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: customToolsKeys.list(workspaceId), + queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), + staleTime: CUSTOM_TOOL_LIST_STALE_TIME, + retryOnMount: true, + }) +} + /** * Hook to fetch custom tools */ export function useCustomTools(workspaceId: string) { - return useQuery({ - queryKey: customToolsKeys.list(workspaceId), - queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), + return useQuery({ + ...customToolsQueryOptions(workspaceId), enabled: !!workspaceId, - staleTime: CUSTOM_TOOL_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 3152276b492..9c5a48e346f 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { removeWorkspaceEnvironmentContract, @@ -26,15 +26,12 @@ export const environmentKeys = { workspace: (workspaceId: string) => [...environmentKeys.workspaces(), workspaceId] as const, } -/** - * Hook to fetch personal environment variables - */ -export function usePersonalEnvironment(options?: { enabled?: boolean }) { - return useQuery({ +export function personalEnvironmentQueryOptions() { + return queryOptions({ queryKey: environmentKeys.personal(), queryFn: ({ signal }) => fetchPersonalEnvironment(signal), - enabled: options?.enabled ?? true, staleTime: PERSONAL_ENVIRONMENT_STALE_TIME, + retryOnMount: true, // Pinned off (not inheriting the desktop QueryClient default): the secrets // manager seeds an editable form from this data, so a background focus // refetch during a concurrent edit would drop the user's unsaved rows. @@ -42,6 +39,28 @@ export function usePersonalEnvironment(options?: { enabled?: boolean }) { }) } +export function workspaceEnvironmentQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: environmentKeys.workspace(workspaceId), + queryFn: ({ signal }) => fetchWorkspaceEnvironment(workspaceId, signal), + staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, + retryOnMount: true, + // See personalEnvironmentQueryOptions: seeds an editable form, so a focus refetch + // during a concurrent workspace-env edit must not clobber unsaved rows. + refetchOnWindowFocus: false, + }) +} + +/** + * Hook to fetch personal environment variables + */ +export function usePersonalEnvironment(options?: { enabled?: boolean }) { + return useQuery({ + ...personalEnvironmentQueryOptions(), + enabled: options?.enabled ?? true, + }) +} + /** * Hook to fetch workspace environment variables */ @@ -50,13 +69,8 @@ export function useWorkspaceEnvironment( options?: { enabled?: boolean; select?: (data: WorkspaceEnvironmentData) => TData } ) { return useQuery({ - queryKey: environmentKeys.workspace(workspaceId), - queryFn: ({ signal }) => fetchWorkspaceEnvironment(workspaceId, signal), + ...workspaceEnvironmentQueryOptions(workspaceId), enabled: Boolean(workspaceId) && (options?.enabled ?? true), - staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, - // See usePersonalEnvironment: seeds an editable form, so a focus refetch - // during a concurrent workspace-env edit must not clobber unsaved rows. - refetchOnWindowFocus: false, select: options?.select, }) } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 49cc718dfc6..d1643cfd0e3 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -1,6 +1,12 @@ import { toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { extractValidationIssues } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' @@ -112,16 +118,23 @@ async function fetchPendingInvitations( ) } +export function pendingInvitationsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: invitationKeys.list(workspaceId), + queryFn: ({ signal }) => fetchPendingInvitations(workspaceId, signal), + staleTime: WORKSPACE_INVITATION_LIST_STALE_TIME, + retryOnMount: true, + }) +} + /** * Fetches pending invitations for a workspace. * @param workspaceId - The workspace ID to fetch invitations for */ export function usePendingInvitations(workspaceId: string | undefined) { return useQuery({ - queryKey: invitationKeys.list(workspaceId ?? ''), - queryFn: ({ signal }) => fetchPendingInvitations(workspaceId as string, signal), + ...pendingInvitationsQueryOptions(workspaceId ?? ''), enabled: Boolean(workspaceId), - staleTime: WORKSPACE_INVITATION_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 50d9498a2f2..64630d7f4b4 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -318,10 +318,6 @@ "modules": 8, "gateways": {} }, - "app/workspace/[workspaceId]/settings/[section]/loading.tsx": { - "modules": 1, - "gateways": {} - }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { "modules": 2410, "gateways": { From 0d84d1e585964d247b44214e65bd0f02117a89f6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 15:23:58 -0700 Subject: [PATCH 2/2] 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. --- .../settings-sidebar/settings-sidebar.tsx | 11 ++--- .../components/settings/settings-sidebar.tsx | 11 ++--- .../use-pending-settings-selection.ts | 41 +++++++++---------- 3 files changed, 27 insertions(+), 36 deletions(-) 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 239c1a55025..3bf9559e163 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 @@ -299,7 +299,7 @@ export function SettingsSidebar({ settingsIndex !== -1 && segments[settingsIndex + 1] ? (segments[settingsIndex + 1] as SettingsSection) : 'general' - const { activeSection, selectPending } = usePendingSettingsSelection(routeSection) + const { activeSection, navigateToSection } = usePendingSettingsSelection(routeSection) const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() @@ -490,15 +490,12 @@ export function SettingsSidebar({ event.preventDefault() return } + event.preventDefault() if (!useSettingsDirtyStore.getState().isDirty) { - selectPending(section) + navigateToSection(section, href) return } - event.preventDefault() - requestLeave(() => { - selectPending(section) - router.replace(href, { scroll: false }) - }) + requestLeave(() => navigateToSection(section, href)) }} > {content} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index fa77370b917..e26d05a36a7 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -92,7 +92,7 @@ export function SettingsSidebar
({ const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) const router = useRouter() - const { activeSection: selectedSection, selectPending } = + const { activeSection: selectedSection, navigateToSection } = usePendingSettingsSelection(activeSection) const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) @@ -195,16 +195,13 @@ export function SettingsSidebar
({ event.preventDefault() return } + event.preventDefault() const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState() if (!isDirty && !navigationBlocked) { - selectPending(item.id) + navigateToSection(item.id, href) return } - event.preventDefault() - requestLeave(() => { - selectPending(item.id) - router.replace(href, { scroll: false }) - }) + requestLeave(() => navigateToSection(item.id, href)) }} > diff --git a/apps/sim/components/settings/use-pending-settings-selection.ts b/apps/sim/components/settings/use-pending-settings-selection.ts index b3ec73f1d39..2237ff02ef2 100644 --- a/apps/sim/components/settings/use-pending-settings-selection.ts +++ b/apps/sim/components/settings/use-pending-settings-selection.ts @@ -1,38 +1,35 @@ 'use client' -import { useCallback, useState } from 'react' -import { usePathname } from 'next/navigation' +import { useCallback, useOptimistic, useTransition } from 'react' +import { useRouter } from 'next/navigation' /** - * The settings row a sidebar click is navigating to, shown as selected until the route commits. + * 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 pending row is dropped whenever the pathname changes — on commit, and on any navigation the - * sidebar did not start, such as the browser's back button. + * 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( routeSection: TSection -): { activeSection: TSection; selectPending: (section: TSection) => void } { - const pathname = usePathname() - const [pending, setPending] = useState<{ section: TSection; pathname: string | null } | null>( - null - ) - - // Cleared, not just compared: returning to this pathname later (back after the commit) must - // not resurrect a settled selection. - if (pending !== null && pending.pathname !== pathname) setPending(null) +): { activeSection: TSection; navigateToSection: (section: TSection, href: string) => void } { + const router = useRouter() + const [activeSection, setOptimisticSection] = useOptimistic(routeSection) + const [, startTransition] = useTransition() - const selectPending = useCallback( - (section: TSection) => setPending({ section, pathname }), - [pathname] + const navigateToSection = useCallback( + (section: TSection, href: string) => { + startTransition(() => { + setOptimisticSection(section) + router.replace(href, { scroll: false }) + }) + }, + [router, setOptimisticSection] ) - return { - activeSection: - pending !== null && pending.pathname === pathname ? pending.section : routeSection, - selectPending, - } + return { activeSection, navigateToSection } }