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.
This commit is contained in:
Anso
2026-05-02 01:50:21 -04:00
committed by GitHub
parent 9ba6b604e3
commit fd05b5ef4b
2 changed files with 67 additions and 18 deletions
@@ -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 (
<div className="flex flex-col gap-4" aria-busy="true">
<Skeleton className="h-8 w-1/3 rounded-md" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
);
}
interface SettingsPageProps {
currentSection: SectionId;
onSectionChange: (section: SectionId) => void;
@@ -212,9 +252,14 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
{activeItem.description}
</p>
) : null}
<SectionGate sectionId={safeSection}>
{sectionElement}
</SectionGate>
{/* Suspense outside SectionGate so the locked-tier
path (which never mounts the lazy children)
does not see a fallback flash. */}
<Suspense fallback={<SectionSkeleton />}>
<SectionGate sectionId={safeSection}>
{sectionElement}
</SectionGate>
</Suspense>
</div>
</ScrollArea>
</div>
+10 -6
View File
@@ -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 {