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 { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import { CustomizeNavSheet } from '@/components/CustomizeNavSheet';
import { isNativePlatform } from '@/lib/platform';
import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -15,9 +15,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { LoginArea } from '@/components/auth/LoginArea';
import { LoginDialog } from '@/components/auth/LoginDialog';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { BackupReminder } from '@/components/BackupReminder';
import { LanguageToggle } from '@/components/LanguageToggle';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext';
@ -54,7 +52,6 @@ import {
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { SetStatusDialog } from '@/components/SetStatusDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@ -76,6 +73,35 @@ const LazyCashuMenuBalance = lazy(() =>
const LazySasquatchGuide = lazy(() =>
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 {
children: React.ReactNode;
@ -371,9 +397,11 @@ function MobileNav({
{isWallet ? (
<>
<Wallet className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
<Suspense fallback={null}>
<LazyCashuBalanceBadge />
</Suspense>
{hasCashuWalletHint() && (
<Suspense fallback={null}>
<LazyCashuBalanceBadge />
</Suspense>
)}
</>
) : (
<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" />
<Suspense fallback={null}>
<LazyCashuBalanceText />
</Suspense>
{hasCashuWalletHint() && (
<Suspense fallback={null}>
<LazyCashuBalanceText />
</Suspense>
)}
</Button>
</Link>
)}
@ -778,9 +808,11 @@ export function Layout({ children, noPadding }: LayoutProps) {
<p className="text-xs text-muted-foreground">Ecash</p>
</div>
</div>
<Suspense fallback={null}>
<LazyCashuMenuBalance />
</Suspense>
{hasCashuWalletHint() && (
<Suspense fallback={null}>
<LazyCashuMenuBalance />
</Suspense>
)}
</div>
</div>
</Link>
@ -973,8 +1005,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
</div>
</header>
{/* Backup Reminder */}
<BackupReminder />
{/* Backup Reminder — lazy (on-demand banner) */}
<Suspense fallback={null}>
<LazyBackupReminder />
</Suspense>
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
{/* noPadding: full-bleed pages (map) manage their own height */}
@ -1048,8 +1082,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
middleTabs={middleTabs}
/>
{/* Bottom-nav customization sheet (mobile) */}
<CustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
{/* Bottom-nav customization sheet (mobile) — lazy (on-demand modal) */}
<Suspense fallback={null}>
<LazyCustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
</Suspense>
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
<Suspense fallback={null}>
@ -1059,18 +1095,22 @@ export function Layout({ children, noPadding }: LayoutProps) {
/>
</Suspense>
{/* Set Status Dialog */}
<SetStatusDialog
open={showStatusDialog}
onOpenChange={setShowStatusDialog}
/>
{/* Set Status Dialog — lazy (on-demand modal) */}
<Suspense fallback={null}>
<LazySetStatusDialog
open={showStatusDialog}
onOpenChange={setShowStatusDialog}
/>
</Suspense>
{/* Add-another-account (multi-account switch on mobile) */}
<LoginDialog
isOpen={showAddAccount}
onClose={() => setShowAddAccount(false)}
onLogin={() => setShowAddAccount(false)}
/>
{/* Add-another-account (multi-account switch on mobile) — lazy */}
<Suspense fallback={null}>
<LazyLoginDialog
isOpen={showAddAccount}
onClose={() => setShowAddAccount(false)}
onLogin={() => setShowAddAccount(false)}
/>
</Suspense>
{/* Newsletter: floating pill + Monday admin reminder */}
<NewsletterOverlay />