mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user