From fd05b5ef4b3da454540946c2920de4d769920ad3 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 2 May 2026 01:50:21 -0400 Subject: [PATCH] feat(frontend): code-split paid-tier settings sections (#870) Every paid-tier React settings section (Users, Webhooks, Security, Labels, ApiTokens, Registries, CloudBackup, NotificationRouting) was statically imported into the bundle Community installs download. SectionGate's runtime tier check hid the components from view but did not gate the download, so paid feature JSX, copy, error messages, and prop interfaces shipped to every Community user. Anyone could open DevTools and read the source. Convert each paid section to a lazy() declaration that imports the component module on demand, and remove the corresponding re-exports from settings/index.ts so rollup actually splits the chunk (without this, the static export path through the barrel collapses the lazy import back into the main bundle and emits an INEFFECTIVE_DYNAMIC_ IMPORT warning). Suspense sits outside SectionGate intentionally: SectionGate short- circuits to TierLockedCard synchronously for locked tiers, so the lazy children never mount and no fallback flashes. The skeleton only appears for the brief window between an unlocked section's chunk request and its first render. Build evidence: 8 new chunks total ~89 kB raw / ~27 kB gzip; main bundle shrunk from 1,537 kB / 421 kB gzip to 1,468 kB / 407 kB gzip. No INEFFECTIVE_DYNAMIC_IMPORT warnings remain. Dev-server runtime test: AccountSection (free, eager) loaded as request 221 on app boot; CloudBackupSection (paid, lazy) loaded as request 351 only after clicking the sidebar entry. Non-settings paid views (FleetView, AuditLogView, etc.) are still static and remain a follow-up. --- .../src/components/settings/SettingsPage.tsx | 69 +++++++++++++++---- frontend/src/components/settings/index.ts | 16 +++-- 2 files changed, 67 insertions(+), 18 deletions(-) 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 {