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 { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
@ -20,7 +20,6 @@ import { SasquatchGuide } from '@/components/SasquatchGuide';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { BackupReminder } from '@/components/BackupReminder';
import { LanguageToggle } from '@/components/LanguageToggle';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
@ -63,6 +62,18 @@ import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
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 {
children: React.ReactNode;
/** If true, removes the bottom padding that prevents bottom nav overlap.
@ -79,17 +90,6 @@ const navItemKeys = [
{ 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({
href,
label,
@ -288,7 +288,6 @@ function MobileHeaderUserMenu({
function MobileNav({
isActive,
cashuBalance,
isLoggedIn,
userPubkey,
userAvatar,
@ -299,7 +298,6 @@ function MobileNav({
middleTabs,
}: {
isActive: (path: string) => boolean;
cashuBalance: number;
isLoggedIn: boolean;
userPubkey?: string;
userAvatar?: string;
@ -370,11 +368,9 @@ function MobileNav({
{isWallet ? (
<>
<Wallet className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
{cashuBalance > 0 && (
<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(cashuBalance)}
</span>
)}
<Suspense fallback={null}>
<LazyCashuBalanceBadge />
</Suspense>
</>
) : (
<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 { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
const { user } = useCurrentUser();
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
const { t } = useTranslation();
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || '');
@ -659,11 +654,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
)}
>
<Wallet className="h-5 w-5" />
{hasCashu && cashuBalance > 0 && (
<span className="text-sm font-bold">
{formatSatsCompact(cashuBalance)}
</span>
)}
<Suspense fallback={null}>
<LazyCashuBalanceText />
</Suspense>
</Button>
</Link>
)}
@ -782,14 +775,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
<p className="text-xs text-muted-foreground">Ecash</p>
</div>
</div>
{hasCashu && (
<div className="text-right">
<p className="font-bold text-orange-500">
{cashuBalance.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
)}
<Suspense fallback={null}>
<LazyCashuMenuBalance />
</Suspense>
</div>
</div>
</Link>
@ -1047,7 +1035,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Bottom Navigation */}
<MobileNav
isActive={isActive}
cashuBalance={cashuBalance}
isLoggedIn={!!user}
userPubkey={user?.pubkey}
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 { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast';
import { LN } from '@getalby/sdk';
import type { LN } from '@getalby/sdk';
export interface NWCConnection {
connectionString: string;
@ -61,6 +61,8 @@ export function useNWCInternal() {
}
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;
const testPromise = new Promise((resolve, reject) => {
try {
@ -165,6 +167,8 @@ export function useNWCInternal() {
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;
try {
client = new LN(connection.connectionString);