Skip to content

perf(web): load the wallet and payment SDKs on demand - #14567

Merged
dylanjeffers merged 1 commit into
mainfrom
perf/lazy-wallet-modals
Aug 19, 2026
Merged

perf(web): load the wallet and payment SDKs on demand#14567
dylanjeffers merged 1 commit into
mainfrom
perf/lazy-wallet-modals

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

3 of 4 in a stack reducing the web entry chunk. Based on #14566. This is the one that needs real review — it touches the auth path and the root provider.

What

ReownAppKitModal runs new WagmiAdapter(...), new SolanaAdapter() and createAppKit(...) at module scope, so a single static import anywhere in the eager graph pinned @reown/*, @walletconnect/*, wagmi and @solana/web3.js into the entry chunk — for every visitor, including everyone who never opens a wallet. @coinflowlabs/react (which bundles the nsure-ai fraud SDK) was there for the same reason.

Together, ~1.9 MB of the entry chunk.

The surprising part: this was on the auth path

services/audius-sdk/auth.ts dereferenced wagmiAdapter.wagmiConfig at module scope, so every email/password user loaded the entire wallet stack to discover they didn't need it.

It now returns to Hedgehog before loading AppKit unless localStorage shows a persisted wallet connection. That probe deliberately errs toward loading: a false negative would silently downgrade an external-wallet user to Hedgehog (a correctness bug), while a false positive only costs a chunk fetch.

Why WagmiProvider stays mounted

It keeps a lightweight bootstrap config and swaps in the adapter's config once AppKit loads. Making the provider itself conditional would remount the entire app the moment a wallet appeared; swapping a context value does not. The bootstrap config sets storage: null so it can't clobber the real config's persisted wagmi.store entry.

Why lazy modal registration is safe

nice-modal-react's NiceModalPlaceholder filters the registry by currently visible ids:

var visibleModalIds = Object.keys(modals).filter(id => !!modals[id]);
var toRender = visibleModalIds.filter(id => MODAL_REGISTRY[id])

So a registered-but-never-shown modal never mounts and its hooks never run — the cost was always the static import graph, not runtime.

Registration moved out of each modal module on purpose. If they still self-registered, the dynamic import would overwrite MODAL_REGISTRY mid-flight and React would swap the element type under 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.

Known limitation

CoinflowPurchaseProtection in routes.tsx is now lazy but still renders unconditionally, so the chunk is still fetched at startup — it's off the entry chunk's parse path rather than deferred outright. Rendering it only on purchase-capable routes would defer it properly, but that's a call for whoever owns payments, since early initialization may be deliberate.

⚠️ Needs QA

External wallet connect / disconnect / sign-in could not be exercised without a real wallet. The Hedgehog (email/password) path is verified: app boots, no console errors, AppKit chunk confirmed not fetched on load, and confirmed to load on demand when a wallet modal opens.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: e0cf59b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dylanjeffers
dylanjeffers force-pushed the perf/lazy-react-virtualized branch from 19a8e48 to e7eb45c Compare August 19, 2026 16:35
Base automatically changed from perf/lazy-react-virtualized to main August 19, 2026 16:51
ReownAppKitModal runs `new WagmiAdapter(...)`, `new SolanaAdapter()` and
`createAppKit(...)` at module scope, so a single static import anywhere in the
eager graph pinned @reown/*, @walletconnect/*, wagmi and @solana/web3.js into
the entry chunk -- for every visitor, including everyone who never opens a
wallet. @coinflowlabs/react (which bundles the nsure-ai fraud SDK) was there for
the same reason.

Together these were ~1.9 MB of the entry chunk.

The surprising edge was auth: services/audius-sdk/auth.ts dereferenced
`wagmiAdapter.wagmiConfig` at *module scope*, so every email/password user
loaded the entire wallet stack to discover they did not need it. It now returns
to Hedgehog before loading AppKit unless localStorage shows a persisted wallet
connection. That probe deliberately errs toward loading: a false negative would
silently downgrade an external-wallet user to Hedgehog, while a false positive
only costs a chunk fetch.

WagmiProvider stays mounted with a lightweight bootstrap config and swaps in the
adapter's config once AppKit loads. Making the provider itself conditional would
remount the entire app the moment a wallet appeared; swapping a context value
does not. The bootstrap config sets `storage: null` so it cannot clobber the
real config's persisted `wagmi.store` entry.

The modals are registered lazily. This is safe because nice-modal-react's
NiceModalPlaceholder filters the registry by *currently visible* ids, so a
registered-but-never-shown modal never mounts and its hooks never run -- the
cost was always the static import graph, not runtime. Registration moved out of
each modal module on purpose: if they still self-registered, the dynamic import
would overwrite MODAL_REGISTRY mid-flight and React would swap the element type
under 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.

CoinflowPurchaseProtection in routes.tsx is now lazy too. It still renders
unconditionally, so the chunk is still fetched at startup -- it is off the entry
chunk's parse path rather than deferred outright. Rendering it only on
purchase-capable routes would defer it properly, but that is a call for whoever
owns payments, since early initialization may be deliberate.

NEEDS QA: external wallet connect / disconnect / sign-in, which could not be
exercised without a real wallet. The Hedgehog (email/password) path is verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dylanjeffers
dylanjeffers force-pushed the perf/lazy-wallet-modals branch from d7eba56 to e0cf59b Compare August 19, 2026 16:52
@dylanjeffers
dylanjeffers merged commit 7684f34 into main Aug 19, 2026
17 checks passed
@dylanjeffers
dylanjeffers deleted the perf/lazy-wallet-modals branch August 19, 2026 16:57
dylanjeffers added a commit that referenced this pull request Aug 19, 2026
…14568)

**4 of 4** in a stack reducing the web entry chunk. Based on #14567.
Includes the CI budget that locks in the whole stack.

## What

`lottie-web` is a **~613 KB animation runtime**. A loading spinner is
the one component that cannot be lazily loaded — it's what renders
*while* things load — so `LoadingSpinner` pinned the whole runtime into
the entry chunk for every visitor.

The spinner is now CSS/SVG.

## The replacement isn't eyeballed

Read from the Lottie source: a 32px circle with a 6px round-capped
stroke whose dash grows 0→100 over 1s while rotating 410°, then shrinks
back over 2s while rotating 719°. The CSS reproduces that with
`stroke-dasharray`/`dashoffset` plus a rotation, and was compared
against the original frozen at six phases across the cycle. It also
honours `prefers-reduced-motion`, which the Lottie version did not.

## ⚠️ The `svg > g > path` structure is load-bearing

Roughly **ten stylesheets** recolour the spinner with `.someClass g path
{ stroke: … }` selectors written against the Lottie output:

```
GatedConditionsPill, Artwork, GiantTrackTile, DownloadRow, BottomButtons,
DesktopSearchBar, SearchBar, LoadingSpinner, PurchaseContentFormFooter, LibraryPage
```

Emitting a `<circle>` would have silently broken spinner colours across
the app. Please keep this in mind if editing the markup.

## Replacing the spinner alone gained nothing

Twelve other components imported `lottie-react` directly (play bar,
search bar, notification reactions, animated buttons, cover photo). They
now route through one `LazyLottie` wrapper.

- Type-only imports like `LottieRefCurrentProps` are preserved as
`import type` so they erase at build time.
- `lottieRef` is an ordinary prop rather than a React ref, so it
forwards through the boundary unchanged; callers keep their existing `if
(lottieRef.current)` guards.

## CI bundle budget

Also points `bundlesize` at the **client** entry chunk. It previously
checked only the SSR *server* chunks at 30 kB, so the bundle every user
downloads had no budget at all — which is how it reached 8.3 MB
unnoticed. The `builds` artifact already contained `build-production`;
the job just never looked at it.

Budget: **1600 kB gzip** against a current 1.49 MB, so it can only
ratchet down.

> The job is gated on `if: github.ref == 'refs/heads/main'`, so it
reports *after* merge rather than blocking a PR. Worth revisiting
separately — that's a PR-blocking policy decision.

## Known tradeoff

`PlayButton` renders only the Lottie with no icon fallback, so it's
briefly empty between mount and the chunk arriving (~270ms locally,
longer on a slow first visit). Giving `LazyLottie` a `fallback` prop and
passing static harmony icons would close that; the right per-button icon
is a design call.

## Stack result

| Entry chunk | raw | gzip |
|---|---|---|
| baseline | 8,333 KB | 2,473 KB |
| after all 4 PRs | **5,618 KB** | **1,521 KB** |
| | **−32.6%** | **−38.5%** |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant