Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions packages/web/src/app/AppProviders.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -28,14 +28,61 @@ 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'

// Wire the platform-agnostic bridge so common (sagas/services) can drive
// nice-modal-react without depending on the package directly.
setNiceModalAdapter({ show: NiceModal.show, hide: NiceModal.hide })

/**
* <WagmiProvider> 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 (
<WagmiProvider
config={appkit?.wagmiAdapter.wagmiConfig ?? bootstrapWagmiConfig}
>
{children}
</WagmiProvider>
)
}

type AppProvidersProps = {
children?: ReactNode
}
Expand Down Expand Up @@ -88,7 +135,7 @@ export const AppProviders = ({ children }: AppProvidersProps) => {
}, [basename])

return (
<WagmiProvider config={wagmiAdapter.wagmiConfig}>
<WagmiGate>
<QueryClientProvider client={queryClient}>
<MediaProvider>
<ReduxProvider store={store}>
Expand All @@ -102,6 +149,6 @@ export const AppProviders = ({ children }: AppProvidersProps) => {
</MediaProvider>
{reactQueryDevtoolsEnabled ? <ReactQueryDevtools /> : null}
</QueryClientProvider>
</WagmiProvider>
</WagmiGate>
)
}
91 changes: 91 additions & 0 deletions packages/web/src/app/appkit.ts
Original file line number Diff line number Diff line change
@@ -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<AppKitModule> | undefined
const listeners = new Set<() => void>()

/** Loads AppKit on demand. Memoized — concurrent callers share one import. */
export const loadAppKit = (): Promise<AppKitModule> => {
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
}
}
27 changes: 27 additions & 0 deletions packages/web/src/app/audiusChain.ts
Original file line number Diff line number Diff line change
@@ -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
76 changes: 71 additions & 5 deletions packages/web/src/app/registerNiceModals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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<any> }>
) => {
const LazyModal = lazy(loader)
const LazyModalBoundary = (props: Record<string, unknown>) =>
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 }))
)
12 changes: 11 additions & 1 deletion packages/web/src/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 <Suspense> boundary below.
*/
const CoinflowPurchaseProtection = lazy(() =>
import('@coinflowlabs/react').then((m) => ({
default: m.CoinflowPurchaseProtection
}))
)

const {
PRIVATE_KEY_EXPORTER_SETTINGS_PAGE,
SIGN_IN_PAGE,
Expand Down
4 changes: 0 additions & 4 deletions packages/web/src/components/buy-sell-modal/BuySellModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -117,6 +116,3 @@ export const BuySellModal = NiceModal.create(() => {
</Modal>
)
})

NiceModal.register('BuySellModal', BuySellModal)
registerNiceModalId('BuySellModal')
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -96,6 +95,3 @@ export const CoinflowOnrampModal = NiceModal.create(() => {
</ModalDrawer>
)
})

NiceModal.register('CoinflowOnramp', CoinflowOnrampModal)
registerNiceModalId('CoinflowOnramp')
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -279,7 +278,4 @@ const SendTokensModal = NiceModal.create(() => {
)
})

NiceModal.register('SendTokensModal', SendTokensModal)
registerNiceModalId('SendTokensModal')

export default SendTokensModal
Loading
Loading