perf(bundle): lazy on-demand modals in Layout; skip the Cashu chunk without a wallet

- BackupReminder, CustomizeNavSheet, SetStatusDialog and LoginDialog are only
  needed after the user acts -> React.lazy (Layout chunk 511 KB -> 486 KB)
- The Cashu balance widget now only mounts when a wallet likely exists (web:
  localStorage hint; native: always, since the seed is in the KeyStore), so
  wallet-less visitors never download the 245 KB Cashu chunk
This commit is contained in:
SkateSpots 2026-09-26 19:41:35 +07:00
parent 8cf35cf7db
commit 81eeba7797

View file

@ -2,7 +2,7 @@ import { useState, useRef, lazy, Suspense } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CustomizeNavSheet } from '@/components/CustomizeNavSheet'; import { isNativePlatform } from '@/lib/platform';
import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences'; import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -15,9 +15,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { LoginDialog } from '@/components/auth/LoginDialog';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide'; import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { BackupReminder } from '@/components/BackupReminder';
import { LanguageToggle } from '@/components/LanguageToggle'; import { LanguageToggle } from '@/components/LanguageToggle';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext'; import { useTranslation } from '@/contexts/LanguageContext';
@ -54,7 +52,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { SetStatusDialog } from '@/components/SetStatusDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
@ -76,6 +73,35 @@ const LazyCashuMenuBalance = lazy(() =>
const LazySasquatchGuide = lazy(() => const LazySasquatchGuide = lazy(() =>
import('@/components/SasquatchGuide').then((m) => ({ default: m.SasquatchGuide })), import('@/components/SasquatchGuide').then((m) => ({ default: m.SasquatchGuide })),
); );
// On-demand modals — only rendered after the user opens them, so keep them out
// of the always-loaded Layout chunk.
const LazyCustomizeNavSheet = lazy(() =>
import('@/components/CustomizeNavSheet').then((m) => ({ default: m.CustomizeNavSheet })),
);
const LazySetStatusDialog = lazy(() =>
import('@/components/SetStatusDialog').then((m) => ({ default: m.SetStatusDialog })),
);
const LazyLoginDialog = lazy(() =>
import('@/components/auth/LoginDialog').then((m) => ({ default: m.LoginDialog })),
);
const LazyBackupReminder = lazy(() =>
import('@/components/BackupReminder').then((m) => ({ default: m.BackupReminder })),
);
/**
* Cheap synchronous hint that a Cashu wallet MIGHT exist, used to decide
* whether to even load the (large) wallet chunk. On native the seed lives in
* the OS KeyStore which we can't query synchronously, so we always load there;
* on web `useCashu` persists the mnemonic in localStorage under this key.
*/
function hasCashuWalletHint(): boolean {
if (isNativePlatform()) return true;
try {
return !!localStorage.getItem('cashu-mnemonic');
} catch {
return false;
}
}
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -371,9 +397,11 @@ function MobileNav({
{isWallet ? ( {isWallet ? (
<> <>
<Wallet className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} /> <Wallet className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
<Suspense fallback={null}> {hasCashuWalletHint() && (
<LazyCashuBalanceBadge /> <Suspense fallback={null}>
</Suspense> <LazyCashuBalanceBadge />
</Suspense>
)}
</> </>
) : ( ) : (
<tab.icon className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} /> <tab.icon className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
@ -657,9 +685,11 @@ export function Layout({ children, noPadding }: LayoutProps) {
)} )}
> >
<Wallet className="h-5 w-5" /> <Wallet className="h-5 w-5" />
<Suspense fallback={null}> {hasCashuWalletHint() && (
<LazyCashuBalanceText /> <Suspense fallback={null}>
</Suspense> <LazyCashuBalanceText />
</Suspense>
)}
</Button> </Button>
</Link> </Link>
)} )}
@ -778,9 +808,11 @@ export function Layout({ children, noPadding }: LayoutProps) {
<p className="text-xs text-muted-foreground">Ecash</p> <p className="text-xs text-muted-foreground">Ecash</p>
</div> </div>
</div> </div>
<Suspense fallback={null}> {hasCashuWalletHint() && (
<LazyCashuMenuBalance /> <Suspense fallback={null}>
</Suspense> <LazyCashuMenuBalance />
</Suspense>
)}
</div> </div>
</div> </div>
</Link> </Link>
@ -973,8 +1005,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
</div> </div>
</header> </header>
{/* Backup Reminder */} {/* Backup Reminder — lazy (on-demand banner) */}
<BackupReminder /> <Suspense fallback={null}>
<LazyBackupReminder />
</Suspense>
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */} {/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
{/* noPadding: full-bleed pages (map) manage their own height */} {/* noPadding: full-bleed pages (map) manage their own height */}
@ -1048,8 +1082,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
middleTabs={middleTabs} middleTabs={middleTabs}
/> />
{/* Bottom-nav customization sheet (mobile) */} {/* Bottom-nav customization sheet (mobile) — lazy (on-demand modal) */}
<CustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} /> <Suspense fallback={null}>
<LazyCustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
</Suspense>
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */} {/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
<Suspense fallback={null}> <Suspense fallback={null}>
@ -1059,18 +1095,22 @@ export function Layout({ children, noPadding }: LayoutProps) {
/> />
</Suspense> </Suspense>
{/* Set Status Dialog */} {/* Set Status Dialog — lazy (on-demand modal) */}
<SetStatusDialog <Suspense fallback={null}>
open={showStatusDialog} <LazySetStatusDialog
onOpenChange={setShowStatusDialog} open={showStatusDialog}
/> onOpenChange={setShowStatusDialog}
/>
</Suspense>
{/* Add-another-account (multi-account switch on mobile) */} {/* Add-another-account (multi-account switch on mobile) — lazy */}
<LoginDialog <Suspense fallback={null}>
isOpen={showAddAccount} <LazyLoginDialog
onClose={() => setShowAddAccount(false)} isOpen={showAddAccount}
onLogin={() => setShowAddAccount(false)} onClose={() => setShowAddAccount(false)}
/> onLogin={() => setShowAddAccount(false)}
/>
</Suspense>
{/* Newsletter: floating pill + Monday admin reminder */} {/* Newsletter: floating pill + Monday admin reminder */}
<NewsletterOverlay /> <NewsletterOverlay />