diff --git a/packages/web/src/app/AppProviders.tsx b/packages/web/src/app/AppProviders.tsx index 10cd522e412..c90bb0492c5 100644 --- a/packages/web/src/app/AppProviders.tsx +++ b/packages/web/src/app/AppProviders.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState, useMemo } from 'react' +import { ReactNode, useState, useMemo, useEffect } from 'react' import { FrostedSurfaceIntensity, ThemePalette } from '@audius/common/models' import { setNiceModalAdapter } from '@audius/common/services' @@ -13,7 +13,7 @@ import { RouterProvider } from 'react-router' import { PersistGate } from 'redux-persist/integration/react' -import { WagmiProvider } from 'wagmi' +import { createConfig, http, WagmiProvider } from 'wagmi' import { REACT_QUERY_DEVTOOLS_KEY, useDevToggle } from 'hooks/useDevToggle' import { useIsMobile } from 'hooks/useIsMobile' @@ -28,7 +28,12 @@ import { getThemePaletteFromStorage } from 'utils/theme/theme' -import { wagmiAdapter } from './ReownAppKitModal' +import { + hasPersistedWalletConnection, + loadAppKit, + useLoadedAppKit +} from './appkit' +import { audiusChain } from './audiusChain' import './registerNiceModals' import { createRoutes } from './routes' @@ -36,6 +41,48 @@ import { createRoutes } from './routes' // nice-modal-react without depending on the package directly. setNiceModalAdapter({ show: NiceModal.show, hide: NiceModal.hide }) +/** + * needs a Config synchronously on first render, but the real one + * is built by Reown's WagmiAdapter — which we no longer load at startup, since + * importing it drags the whole AppKit graph into the entry chunk. This minimal + * stand-in fills the gap until AppKit actually loads. + * + * `storage: null` matters: the real config persists to `wagmi.store`, and a + * second config writing that key would clobber it along with the + * hasPersistedWalletConnection() probe that reads it. + */ +const bootstrapWagmiConfig = createConfig({ + chains: [audiusChain], + transports: { [audiusChain.id]: http() }, + storage: null +}) + +/** + * Mounts WagmiProvider unconditionally and swaps in the adapter's config once + * AppKit loads. Swapping the `config` prop re-renders context consumers but does + * not unmount the subtree — making WagmiProvider itself conditional would remount + * the entire app the moment a wallet appeared. + */ +const WagmiGate = ({ children }: { children: ReactNode }) => { + const appkit = useLoadedAppKit() + + useEffect(() => { + // Restore a previously connected external wallet. Users who never connected + // one never pay for the chunk. + if (!appkit && hasPersistedWalletConnection()) { + loadAppKit() + } + }, [appkit]) + + return ( + + {children} + + ) +} + type AppProvidersProps = { children?: ReactNode } @@ -88,7 +135,7 @@ export const AppProviders = ({ children }: AppProvidersProps) => { }, [basename]) return ( - + @@ -102,6 +149,6 @@ export const AppProviders = ({ children }: AppProvidersProps) => { {reactQueryDevtoolsEnabled ? : null} - + ) } diff --git a/packages/web/src/app/appkit.ts b/packages/web/src/app/appkit.ts new file mode 100644 index 00000000000..84581aa49b6 --- /dev/null +++ b/packages/web/src/app/appkit.ts @@ -0,0 +1,91 @@ +import { useSyncExternalStore } from 'react' + +/** + * Lazy access to the Reown AppKit singletons. + * + * `ReownAppKitModal` runs `new WagmiAdapter(...)`, `new SolanaAdapter()` and + * `createAppKit(...)` at module scope, so a single static import anywhere in the + * eager graph pins `@reown/appkit`, both adapters, `@walletconnect/*` and + * `@solana/web3.js` into the entry chunk — for every visitor, including the + * majority who never touch a wallet. + * + * Everything reaching AppKit from eagerly-loaded code should go through here + * instead of importing `ReownAppKitModal` directly. Code that is already behind + * a `React.lazy` boundary (wallet pages, wallet modals) can keep importing it + * directly — those chunks are only fetched when the user gets there. + */ +type AppKitModule = typeof import('./ReownAppKitModal') + +let loaded: AppKitModule | undefined +let pending: Promise | undefined +const listeners = new Set<() => void>() + +/** Loads AppKit on demand. Memoized — concurrent callers share one import. */ +export const loadAppKit = (): Promise => { + if (loaded) return Promise.resolve(loaded) + if (!pending) { + pending = import('./ReownAppKitModal').then((mod) => { + loaded = mod + listeners.forEach((notify) => notify()) + return mod + }) + } + return pending +} + +/** + * The AppKit module if it has already loaded, else `undefined`. Never triggers + * a load — for callers that only need to act on an *existing* connection (e.g. + * disconnect on sign-out: if AppKit never loaded there is nothing to disconnect). + */ +export const getLoadedAppKit = (): AppKitModule | undefined => loaded + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +const getServerSnapshot = (): AppKitModule | undefined => undefined + +/** React binding for {@link getLoadedAppKit}; re-renders once AppKit loads. */ +export const useLoadedAppKit = (): AppKitModule | undefined => + useSyncExternalStore(subscribe, getLoadedAppKit, getServerSnapshot) + +/** + * wagmi's default storage key. `WagmiAdapter` passes no `storage` override, so + * `@wagmi/core`'s `createStorage` falls back to the `wagmi` prefix. + */ +const WAGMI_STORAGE_KEY = 'wagmi.store' + +/** + * Whether this browser has a persisted external-wallet connection, i.e. whether + * AppKit needs to load at startup to restore it. + * + * Deliberately biased toward `true`. A false negative silently downgrades an + * external-wallet user to Hedgehog, which is a correctness bug; a false positive + * only costs an unnecessary chunk load. When the key is present but unreadable + * we load AppKit and let wagmi decide. + */ +export const hasPersistedWalletConnection = (): boolean => { + let raw: string | null = null + try { + raw = window.localStorage.getItem(WAGMI_STORAGE_KEY) + } catch { + // localStorage unavailable (SSR, private mode). Nothing could have been + // persisted, so there is no connection to restore. + return false + } + if (!raw) return false + try { + const { state } = JSON.parse(raw) ?? {} + if (!state) return true + // wagmi serializes `connections` as { __type: 'Map', value: [...] }. + const connections = state.connections?.value ?? state.connections + const count = Array.isArray(connections) ? connections.length : 0 + return Boolean(state.current) || count > 0 + } catch { + return true + } +} diff --git a/packages/web/src/app/audiusChain.ts b/packages/web/src/app/audiusChain.ts new file mode 100644 index 00000000000..c9d511dca5a --- /dev/null +++ b/packages/web/src/app/audiusChain.ts @@ -0,0 +1,27 @@ +import { type Chain } from 'viem' + +import { env } from 'services/env' + +/** + * Audius ACDC chain (now ports to Core). + * + * Deliberately lives outside `ReownAppKitModal`. That module constructs + * `WagmiAdapter`, `SolanaAdapter` and `createAppKit` as import-time side + * effects, so importing *any* symbol from it — even a plain config object like + * this one — pulls the whole AppKit graph (`@reown/*`, `@walletconnect/*`) + * into the importing chunk. + * + * Most consumers only need `audiusChain.id`. Keeping the definition free of + * Reown imports lets them stay out of that graph entirely. + * + * Typed against viem's `Chain` rather than `@reown/appkit/networks` so there is + * no `@reown` coupling here at all, even at the type level. + */ +export const audiusChain = { + id: env.AUDIUS_NETWORK_CHAIN_ID, + name: 'Audius', + nativeCurrency: { name: '-', symbol: '-', decimals: 18 }, + rpcUrls: { + default: { http: [`${env.API_URL}/core/erpc`] } + } +} as const satisfies Chain diff --git a/packages/web/src/app/registerNiceModals.ts b/packages/web/src/app/registerNiceModals.ts index 91ec7c5a8a8..fab98614aea 100644 --- a/packages/web/src/app/registerNiceModals.ts +++ b/packages/web/src/app/registerNiceModals.ts @@ -8,12 +8,15 @@ * * Add new NiceModal-managed modals here as they migrate. */ +import { createElement, lazy, Suspense, type ComponentType } from 'react' + +import { registerNiceModalId } from '@audius/common/services' +import NiceModal from '@ebay/nice-modal-react' + import 'components/add-cash-modal/AddCashModal' import 'components/add-to-collection/desktop/AddToCollectionModal' import 'components/album-track-remove-confirmation-modal/AlbumTrackRemoveConfirmationModal' import 'components/artist-pick-modal/ArtistPickModal' -import 'components/buy-sell-modal/BuySellModal' -import 'components/coinflow-onramp-modal/CoinflowOnrampModal' import 'components/delete-playlist-confirmation-modal/DeletePlaylistConfirmationModal' import 'components/delete-track-confirmation-modal/DeleteTrackConfirmationModal' import 'components/download-track-archive-modal/DownloadTrackArchiveModal' @@ -35,7 +38,6 @@ import 'components/replace-track-confirmation-modal/ReplaceTrackConfirmationModa import 'components/replace-track-progress-modal/ReplaceTrackProgressModal' import 'components/rewards/modals/ClaimAllRewardsModal' import 'components/rewards/modals/TopAPI' -import 'components/send-tokens-modal/SendTokensModal' import 'components/share-modal/ShareModal' import 'components/transaction-details-modal/TransactionDetailsModal' import 'components/upload-confirmation-modal/UploadConfirmationModal' @@ -45,10 +47,74 @@ import 'components/user-badges/TierExplainerModal' import 'components/wait-for-download-modal/WaitForDownloadModal' import 'components/welcome-modal/WelcomeModal' import 'components/withdraw-usdc-modal/WithdrawUSDCModal' -import 'components/withdraw-usdc-modal/components/CoinflowWithdrawModal' import 'pages/audio-page/components/modals/AudioBreakdownModal' -import 'pages/audio-page/components/modals/ConnectedWalletsModal' import 'pages/audio-page/components/modals/TransferAudioMobileDrawer' import 'pages/chat-page/components/ChatBlastModal' import 'pages/fan-club-detail-page/components/ClaimVestedCoinsModal' import 'pages/rewards-page/components/modals/ChallengeRewardsModal/ChallengeRewardsModal' + +/** + * Wallet modals, registered lazily. + * + * These three pull in the Reown AppKit graph (`@reown/*`, `@walletconnect/*`, + * `@solana/web3.js`). Importing them here for their registration side effect put + * roughly 1.5 MB of wallet SDK in the entry chunk for every visitor, including + * everyone who never opens a wallet. + * + * Registering a lazy component instead is safe because NiceModal only renders + * modals that are currently *visible* (`NiceModalPlaceholder` filters the + * registry by the visible ids), so nothing here mounts — or suspends — until the + * user actually opens one. + * + * Registration lives here rather than in each modal module on purpose: if those + * modules still self-registered, the dynamic import would overwrite + * MODAL_REGISTRY mid-flight and React would swap the element type underneath an + * open modal, remounting it and losing its state. + * + * The Suspense boundary is local because `NiceModal.Provider` mounts its + * placeholder outside the only boundary in routes.tsx. + */ +const registerLazyModal = ( + id: string, + loader: () => Promise<{ default: ComponentType }> +) => { + const LazyModal = lazy(loader) + const LazyModalBoundary = (props: Record) => + createElement(Suspense, { fallback: null }, createElement(LazyModal, props)) + LazyModalBoundary.displayName = `LazyModal(${id})` + NiceModal.register(id, LazyModalBoundary) + registerNiceModalId(id) +} + +registerLazyModal('BuySellModal', () => + import('components/buy-sell-modal/BuySellModal').then((m) => ({ + default: m.BuySellModal + })) +) +registerLazyModal( + 'SendTokensModal', + () => import('components/send-tokens-modal/SendTokensModal') +) +registerLazyModal('ConnectedWallets', () => + import('pages/audio-page/components/modals/ConnectedWalletsModal').then( + (m) => ({ default: m.ConnectedWalletsModal }) + ) +) + +/** + * Coinflow modals, registered lazily. + * + * `@coinflowlabs/react` bundles the nsure-ai fraud-detection SDK (~294 KB of + * source on its own). Neither is needed until a user actually reaches a + * purchase or withdrawal flow. + */ +registerLazyModal('CoinflowOnramp', () => + import('components/coinflow-onramp-modal/CoinflowOnrampModal').then((m) => ({ + default: m.CoinflowOnrampModal + })) +) +registerLazyModal('CoinflowWithdraw', () => + import( + 'components/withdraw-usdc-modal/components/CoinflowWithdrawModal' + ).then((m) => ({ default: m.CoinflowWithdrawModal })) +) diff --git a/packages/web/src/app/routes.tsx b/packages/web/src/app/routes.tsx index 9e643b9f085..d0ac9e5ef10 100644 --- a/packages/web/src/app/routes.tsx +++ b/packages/web/src/app/routes.tsx @@ -2,7 +2,6 @@ import React, { lazy, Suspense, useEffect } from 'react' import { SyncLocalStorageUserProvider } from '@audius/common/api' import { route } from '@audius/common/utils' -import { CoinflowPurchaseProtection } from '@coinflowlabs/react' import NiceModal from '@ebay/nice-modal-react' import type { RouteObject } from 'react-router' import { Navigate, Outlet, useNavigate } from 'react-router' @@ -24,6 +23,17 @@ import { AudiusQueryProvider } from './AudiusQueryProvider' import { ThemeProvider } from './ThemeProvider' import WebPlayer from './web-player/WebPlayer' +/** + * `@coinflowlabs/react` pulls in the nsure-ai fraud-detection SDK, which was + * landing in the entry chunk for every visitor. Nothing here is needed before + * first paint, and this already renders inside a boundary below. + */ +const CoinflowPurchaseProtection = lazy(() => + import('@coinflowlabs/react').then((m) => ({ + default: m.CoinflowPurchaseProtection + })) +) + const { PRIVATE_KEY_EXPORTER_SETTINGS_PAGE, SIGN_IN_PAGE, diff --git a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx index 0bc4535576e..5ff9d328256 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellModal.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellModal.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo, useState, useEffect } from 'react' import { buySellMessages } from '@audius/common/messages' -import { registerNiceModalId } from '@audius/common/services' import { useBuySellModal, useAddCashModal } from '@audius/common/store' import { IconJupiterLogo, @@ -117,6 +116,3 @@ export const BuySellModal = NiceModal.create(() => { ) }) - -NiceModal.register('BuySellModal', BuySellModal) -registerNiceModalId('BuySellModal') diff --git a/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx index b0f61566dff..4509251c22f 100644 --- a/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx +++ b/packages/web/src/components/coinflow-onramp-modal/CoinflowOnrampModal.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { useCoinflowAdapter } from '@audius/common/hooks' -import { registerNiceModalId } from '@audius/common/services' import { coinflowModalUIActions, useCoinflowOnrampModal @@ -96,6 +95,3 @@ export const CoinflowOnrampModal = NiceModal.create(() => { ) }) - -NiceModal.register('CoinflowOnramp', CoinflowOnrampModal) -registerNiceModalId('CoinflowOnramp') diff --git a/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx b/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx index ea4e74e01c5..6a2ddd93353 100644 --- a/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx +++ b/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx @@ -3,7 +3,6 @@ import { useCallback, useState, useEffect, useRef } from 'react' import { useSendCoins } from '@audius/common/api' import { walletMessages } from '@audius/common/messages' import { SolanaWalletAddress, User } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { useSendTokensModal } from '@audius/common/store' import NiceModal, { useModal } from '@ebay/nice-modal-react' @@ -279,7 +278,4 @@ const SendTokensModal = NiceModal.create(() => { ) }) -NiceModal.register('SendTokensModal', SendTokensModal) -registerNiceModalId('SendTokensModal') - export default SendTokensModal diff --git a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx index d073222d766..f79f3fb9e16 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { useCoinflowWithdrawalAdapter } from '@audius/common/hooks' -import { registerNiceModalId } from '@audius/common/services' import { withdrawUSDCActions, withdrawUSDCSelectors @@ -90,6 +89,3 @@ export const CoinflowWithdrawModal = NiceModal.create(() => { ) }) - -NiceModal.register('CoinflowWithdraw', CoinflowWithdrawModal) -registerNiceModalId('CoinflowWithdraw') diff --git a/packages/web/src/pages/audio-page/components/modals/ConnectedWalletsModal.tsx b/packages/web/src/pages/audio-page/components/modals/ConnectedWalletsModal.tsx index 6ed63bcf810..c182dda943b 100644 --- a/packages/web/src/pages/audio-page/components/modals/ConnectedWalletsModal.tsx +++ b/packages/web/src/pages/audio-page/components/modals/ConnectedWalletsModal.tsx @@ -5,7 +5,6 @@ import { useRemoveAssociatedWallet } from '@audius/common/api' import { Chain } from '@audius/common/models' -import { registerNiceModalId } from '@audius/common/services' import { Button, Flex, @@ -251,6 +250,3 @@ export const ConnectedWalletsModal = NiceModal.create(() => { ) }) - -NiceModal.register('ConnectedWallets', ConnectedWalletsModal) -registerNiceModalId('ConnectedWallets') diff --git a/packages/web/src/services/audius-sdk/auth.ts b/packages/web/src/services/audius-sdk/auth.ts index 1c3c9f965e1..e0d68fbe770 100644 --- a/packages/web/src/services/audius-sdk/auth.ts +++ b/packages/web/src/services/audius-sdk/auth.ts @@ -9,13 +9,12 @@ import { import { getWalletClient } from '@wagmi/core' import { type WalletClient } from 'viem' -import { audiusChain, wagmiAdapter } from 'app/ReownAppKitModal' +import { hasPersistedWalletConnection, loadAppKit } from 'app/appkit' +import { audiusChain } from 'app/audiusChain' import { env } from '../env' import { localStorage } from '../local-storage' -const wagmiConfig = wagmiAdapter.wagmiConfig - export const getAudiusWalletClient = async (): Promise => { // Check if the user has already connected Hedgehog first... await authService.hedgehogInstance.waitUntilReady() @@ -29,9 +28,19 @@ export const getAudiusWalletClient = async (): Promise => { return createHedgehogWalletClient(authService.hedgehogInstance) } + // No external wallet has ever connected in this browser, so there is nothing + // to restore. Return before loading AppKit — this is the common path for + // every email/password user and keeps the wallet SDK out of startup. + if (!hasPersistedWalletConnection()) { + return createHedgehogWalletClient(authService.hedgehogInstance) + } + // Try the connected external wallet next... console.debug('[audiusSdk] Initializing SDK with external wallet...') + const { wagmiAdapter } = await loadAppKit() + const wagmiConfig = wagmiAdapter.wagmiConfig + // Wait for the wallet to finish connecting/reconnecting if ( wagmiConfig.state.status === 'reconnecting' || diff --git a/packages/web/src/store/sign-out/sagas.ts b/packages/web/src/store/sign-out/sagas.ts index 4cb39c0ef35..cddb02c2d1a 100644 --- a/packages/web/src/store/sign-out/sagas.ts +++ b/packages/web/src/store/sign-out/sagas.ts @@ -8,15 +8,13 @@ import { import { disconnect } from '@wagmi/core' import { takeLatest, put, call } from 'redux-saga/effects' -import { wagmiAdapter } from 'app/ReownAppKitModal' +import { getLoadedAppKit } from 'app/appkit' import { make } from 'common/store/analytics/actions' import { signOut } from 'store/sign-out/signOut' import { push } from 'utils/navigation' const { resetAccount, unsubscribeBrowserPushNotifications } = accountActions const { signOut: signOutAction } = signOutActions -const wagmiConfig = wagmiAdapter.wagmiConfig - function* watchSignOut() { const localStorage = yield* getContext('localStorage') const authService = yield* getContext('authService') @@ -24,7 +22,10 @@ function* watchSignOut() { yield takeLatest( signOutAction.type, function* (action: ReturnType) { - if (wagmiConfig.state.status === 'connected') { + // Only reachable if AppKit was loaded, which only happens once a wallet + // is actually in play — if it never loaded there is nothing to disconnect. + const wagmiConfig = getLoadedAppKit()?.wagmiAdapter.wagmiConfig + if (wagmiConfig && wagmiConfig.state.status === 'connected') { yield call(disconnect, wagmiConfig) } yield put(resetAccount())