perf(bundle): lazy-load the Cashu balance; load @getalby/sdk on demand

- Layout (app shell) imported useCashu, pulling the Cashu stack into the entry
  chunk every visitor downloads. The three balance widgets now live in
  components/wallet/CashuBalance.tsx and load via React.lazy.
- useNWC statically imported @getalby/sdk; it is now a type-only import plus a
  dynamic import at call sites, so the SDK only loads when NWC is used.
Entry chunk: 1879 KB -> 1835 KB.
This commit is contained in:
SkateSpots 2026-09-26 19:31:01 +07:00
parent fb5ebb5cd4
commit e6f1fe2673
3 changed files with 73 additions and 36 deletions

View file

@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; 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';
@ -20,7 +20,6 @@ import { SasquatchGuide } from '@/components/SasquatchGuide';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide'; import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { BackupReminder } from '@/components/BackupReminder'; import { BackupReminder } from '@/components/BackupReminder';
import { LanguageToggle } from '@/components/LanguageToggle'; import { LanguageToggle } from '@/components/LanguageToggle';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext'; import { useTranslation } from '@/contexts/LanguageContext';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
@ -63,6 +62,18 @@ import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay'; import { NewsletterOverlay } from '@/components/NewsletterOverlay';
import { NotificationBell } from '@/components/NotificationBell'; import { NotificationBell } from '@/components/NotificationBell';
// Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why
// (keeps the Cashu stack out of the initial bundle).
const LazyCashuBalanceText = lazy(() =>
import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuBalanceText })),
);
const LazyCashuBalanceBadge = lazy(() =>
import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuBalanceBadge })),
);
const LazyCashuMenuBalance = lazy(() =>
import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuMenuBalance })),
);
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** If true, removes the bottom padding that prevents bottom nav overlap. /** If true, removes the bottom padding that prevents bottom nav overlap.
@ -79,17 +90,6 @@ const navItemKeys = [
{ href: '/crews', labelKey: 'nav.crews', icon: Users }, { href: '/crews', labelKey: 'nav.crews', icon: Users },
]; ];
// Format sats for display (e.g., 1234 -> "1.2k", 12345 -> "12k")
function formatSatsCompact(sats: number): string {
if (sats >= 1000000) {
return `${(sats / 1000000).toFixed(1)}M`;
}
if (sats >= 1000) {
return `${(sats / 1000).toFixed(sats >= 10000 ? 0 : 1)}k`;
}
return sats.toString();
}
function NavLink({ function NavLink({
href, href,
label, label,
@ -288,7 +288,6 @@ function MobileHeaderUserMenu({
function MobileNav({ function MobileNav({
isActive, isActive,
cashuBalance,
isLoggedIn, isLoggedIn,
userPubkey, userPubkey,
userAvatar, userAvatar,
@ -299,7 +298,6 @@ function MobileNav({
middleTabs, middleTabs,
}: { }: {
isActive: (path: string) => boolean; isActive: (path: string) => boolean;
cashuBalance: number;
isLoggedIn: boolean; isLoggedIn: boolean;
userPubkey?: string; userPubkey?: string;
userAvatar?: string; userAvatar?: string;
@ -370,11 +368,9 @@ 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')} />
{cashuBalance > 0 && ( <Suspense fallback={null}>
<span className="absolute -top-1 -right-1.5 min-w-[16px] h-4 px-1 text-[8px] font-bold bg-orange-500 text-white rounded-full flex items-center justify-center leading-none ring-2 ring-black"> <LazyCashuBalanceBadge />
{formatSatsCompact(cashuBalance)} </Suspense>
</span>
)}
</> </>
) : ( ) : (
<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')} />
@ -474,7 +470,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
const [showAddAccount, setShowAddAccount] = useState(false); const [showAddAccount, setShowAddAccount] = useState(false);
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide(); const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
const { t } = useTranslation(); const { t } = useTranslation();
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts(); const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || ''); const author = useAuthor(user?.pubkey || '');
@ -659,11 +654,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
)} )}
> >
<Wallet className="h-5 w-5" /> <Wallet className="h-5 w-5" />
{hasCashu && cashuBalance > 0 && ( <Suspense fallback={null}>
<span className="text-sm font-bold"> <LazyCashuBalanceText />
{formatSatsCompact(cashuBalance)} </Suspense>
</span>
)}
</Button> </Button>
</Link> </Link>
)} )}
@ -782,14 +775,9 @@ 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>
{hasCashu && ( <Suspense fallback={null}>
<div className="text-right"> <LazyCashuMenuBalance />
<p className="font-bold text-orange-500"> </Suspense>
{cashuBalance.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
)}
</div> </div>
</div> </div>
</Link> </Link>
@ -1047,7 +1035,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
<MobileNav <MobileNav
isActive={isActive} isActive={isActive}
cashuBalance={cashuBalance}
isLoggedIn={!!user} isLoggedIn={!!user}
userPubkey={user?.pubkey} userPubkey={user?.pubkey}
userAvatar={userAvatar} userAvatar={userAvatar}

View file

@ -0,0 +1,46 @@
import { useCashu } from '@/hooks/wallet/useCashu';
/**
* Cashu balance displays, split out of `Layout`.
*
* WHY: `Layout` is in the initial app shell, and importing `useCashu` there
* pulled the entire Cashu stack (`coco-cashu-core` + `@cashu/cashu-ts` + seed
* crypto) into the entry chunk that every visitor downloads. These three tiny
* components are loaded lazily by `Layout`, so the wallet code only ships when
* a balance is actually displayed.
*/
function formatSatsCompact(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M`;
if (sats >= 1000) return `${(sats / 1000).toFixed(sats >= 10000 ? 0 : 1)}k`;
return sats.toString();
}
/** Desktop header: sats next to the wallet nav button. */
export function CashuBalanceText() {
const { totalBalance, isInitialized } = useCashu();
if (!isInitialized || totalBalance <= 0) return null;
return <span className="text-sm font-bold">{formatSatsCompact(totalBalance)}</span>;
}
/** Mobile bottom nav: small badge on the wallet tab. */
export function CashuBalanceBadge() {
const { totalBalance, isInitialized } = useCashu();
if (!isInitialized || totalBalance <= 0) return null;
return (
<span className="absolute -top-1 -right-1.5 min-w-[16px] h-4 px-1 text-[8px] font-bold bg-orange-500 text-white rounded-full flex items-center justify-center leading-none ring-2 ring-black">
{formatSatsCompact(totalBalance)}
</span>
);
}
/** Mobile menu: the "Cashu / Ecash" row's balance. */
export function CashuMenuBalance() {
const { totalBalance, isInitialized } = useCashu();
if (!isInitialized) return null;
return (
<div className="text-right">
<p className="font-bold text-orange-500">{totalBalance.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { LN } from '@getalby/sdk'; import type { LN } from '@getalby/sdk';
export interface NWCConnection { export interface NWCConnection {
connectionString: string; connectionString: string;
@ -61,6 +61,8 @@ export function useNWCInternal() {
} }
try { try {
// Load the Getalby SDK on demand so it stays out of the initial bundle.
const { LN } = await import('@getalby/sdk');
let timeoutId: NodeJS.Timeout | undefined; let timeoutId: NodeJS.Timeout | undefined;
const testPromise = new Promise((resolve, reject) => { const testPromise = new Promise((resolve, reject) => {
try { try {
@ -165,6 +167,8 @@ export function useNWCInternal() {
throw new Error('Invalid connection: missing connection string'); throw new Error('Invalid connection: missing connection string');
} }
// Load the Getalby SDK on demand so it stays out of the initial bundle.
const { LN } = await import('@getalby/sdk');
let client: LN; let client: LN;
try { try {
client = new LN(connection.connectionString); client = new LN(connection.connectionString);