diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 22953c60..e64f3873 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState, useCallback, useMemo, useEffect } from 'react'; +import { useLayoutEffect, useRef, useState, useCallback, useMemo, useEffect, lazy, Suspense } from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { CommandDialog, @@ -9,29 +9,22 @@ import { CommandList, } from '@/components/ui/command'; import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead'; +import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { NodeManager } from '../NodeManager'; import { SSOSection } from '../SSOSection'; -import { ApiTokensSection } from '../ApiTokensSection'; -import { RegistriesSection } from '../RegistriesSection'; import { AccountSection, AppearanceSection, LicenseSection, - UsersSection, SystemSection, NotificationsSection, - NotificationRoutingSection, - WebhooksSection, - SecuritySection, - CloudBackupSection, DeveloperSection, AppStoreSection, SupportSection, AboutSection, - LabelsSection, SETTINGS_ITEMS, SETTINGS_GROUPS, getSettingsItem, @@ -46,6 +39,53 @@ import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsCon import { TierLockChip } from './TierLockChip'; import { cn } from '@/lib/utils'; +// Paid-tier sections are loaded on demand. SectionGate short-circuits to a +// TierLockedCard for Community / wrong-variant operators before reaching the +// JSX that would mount these components, so the chunks are never fetched on +// those installs and the JSX, copy, and prop shapes never enter the bundle a +// Community user downloads. Bypassing the ./index barrel keeps each component +// in its own chunk; importing through the barrel would pull every named +// export into the same chunk and defeat the split. +const UsersSection = lazy(() => + import('./UsersSection').then(m => ({ default: m.UsersSection })), +); +const WebhooksSection = lazy(() => + import('./WebhooksSection').then(m => ({ default: m.WebhooksSection })), +); +const SecuritySection = lazy(() => + import('./SecuritySection').then(m => ({ default: m.SecuritySection })), +); +const LabelsSection = lazy(() => + import('./LabelsSection').then(m => ({ default: m.LabelsSection })), +); +const NotificationRoutingSection = lazy(() => + import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })), +); +const CloudBackupSection = lazy(() => + import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })), +); +const ApiTokensSection = lazy(() => + import('../ApiTokensSection').then(m => ({ default: m.ApiTokensSection })), +); +const RegistriesSection = lazy(() => + import('../RegistriesSection').then(m => ({ default: m.RegistriesSection })), +); + +// Approximation of a settings section's first-paint shape: a header strip and +// a couple of field rows. Visible only on the brief window between an unlocked +// section's chunk request and its first render. SectionGate's TierLockedCard +// path never mounts the lazy children, so this never flashes for locked tiers. +function SectionSkeleton() { + return ( +
+ + + + +
+ ); +} + interface SettingsPageProps { currentSection: SectionId; onSectionChange: (section: SectionId) => void; @@ -212,9 +252,14 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp {activeItem.description}

) : null} - - {sectionElement} - + {/* Suspense outside SectionGate so the locked-tier + path (which never mounts the lazy children) + does not see a fallback flash. */} + }> + + {sectionElement} + + diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index ea3b24d9..48e49732 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -1,18 +1,22 @@ +// Free-tier sections are exported eagerly: every operator sees them on every +// install so static imports keep first-paint fast. export { AccountSection } from './AccountSection'; export { AppearanceSection } from './AppearanceSection'; export { LicenseSection } from './LicenseSection'; -export { UsersSection } from './UsersSection'; export { SystemSection } from './SystemSection'; export { NotificationsSection } from './NotificationsSection'; -export { WebhooksSection } from './WebhooksSection'; -export { SecuritySection } from './SecuritySection'; -export { CloudBackupSection } from './CloudBackupSection'; export { DeveloperSection } from './DeveloperSection'; export { AppStoreSection } from './AppStoreSection'; export { SupportSection } from './SupportSection'; export { AboutSection } from './AboutSection'; -export { LabelsSection } from './LabelsSection'; -export { NotificationRoutingSection } from './NotificationRoutingSection'; + +// Paid-tier sections (UsersSection, WebhooksSection, SecuritySection, +// LabelsSection, CloudBackupSection, NotificationRoutingSection) are NOT +// re-exported from this barrel. They are dynamically imported with +// React.lazy in SettingsPage.tsx so their JSX, copy, and prop shapes do not +// land in the bundle a Community user downloads. Re-adding any of them as a +// static export here would defeat the split: rollup detects the static path +// and keeps the module in the main chunk regardless of the lazy() call. export { DEFAULT_SETTINGS } from './types'; export type { PatchableSettings, SectionId, Agent } from './types'; export {