You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every Ledger Live app imports all ~29 coin families at startup through generated barrel files in libs/ledger-live-common/src/generated/.
~20 MB of compressed JS parsed and executed before the app is interactive, for every user, every boot — regardless of which coins they hold.
// generated/bridge.ts — the original problemexport*from"@ledgerhq/coin-bitcoin/bridge";export*from"@ledgerhq/coin-evm/bridge";export*from"@ledgerhq/coin-tron/bridge";// registers protobuf — breaks Bun runtimesexport*from"@ledgerhq/coin-solana/bridge";// … 25 more, all evaluated eagerly, always
Twelve files like this. One per domain (bridge, transaction, serialization, …).
Baseline:
Metric
Value
CI desktop unit tests
~18 min
LLD boot time (M1, optimised build)
~1.4 s (T-load)
LLM bundle
~56.4 MB
2 — Exploration 1: Go straight to async import() (PR #16002, Apr 2026)
The right fix is obvious: replace every barrel export with a dynamic import().
- export * from "@ledgerhq/coin-bitcoin/bridge";+ loadBridge: () => import("@ledgerhq/coin-bitcoin/bridge.js"),
We explored this fully. First measurements were very promising:
DEVELOP
T-load: 1.340s / TOTAL BOOT: 1959ms
explo/live-28411 (async import())
T-load: 1.048s / TOTAL BOOT: 1673ms
→ T-load 30% faster
On desktop unit tests (local Mac): 8 min → 3 min.
Bundle size: −5 MB LLD, −6 MB LLM (coin code absent from main chunk).
The problem:getAccountBridge and getCurrencyBridge are synchronous and used everywhere.
Making them async propagates through 267 files across LLD, LLM, live-common, live-wallet:
// React components → need new async-aware hook (React 19 use() + Suspense)constbridge=useAccountBridge<FamilyTx>(account,parentAccount);// Event handlers / effects → manual .then()getAccountBridge(account).then(bridge=>bridge.sync(...));// RxJS pipelines → from() wrapperfrom(getAccountBridge(account)).pipe(switchMap(b=>b.sync(...)));// Redux reducers → can't be async, must use sync framework helpersimport{clearAccount,isAccountEmpty}from"@ledgerhq/ledger-wallet-framework/account";// Operation checks → useState + useEffect everywhereconst[isEditable,setIsEditable]=useState(false);useEffect(()=>{isEditableOperation({account, op}).then(setIsEditable);},[...]);
Too much blast radius for a single PR. We needed a staged approach.
3 — Phase 1: The require() Registry (PR #16031, merged April 9 2026)
Plan: replace the 12 barrel files with a require()-based registry. require() inside a function is lazy in Node.js — the module loads only on first call. Key benefit: public API stays 100% synchronous — zero call-site changes.
// BEFORE — generated/bridge.ts (eager, always)
- export * from "@ledgerhq/coin-bitcoin/bridge";- export * from "@ledgerhq/coin-evm/bridge";- // … 27 more
// AFTER — coin-modules/loaders.ts
+ {+ family: "bitcoin",+ loadSetup: () => require("../families/bitcoin/setup"),+ loadTransaction: () => require("@ledgerhq/coin-bitcoin/transaction").default,+ loadBridge: () => require("../families/bitcoin/bridge").default,+ },+ // … 28 more families
Registry structure:
coin-modules/
registry.ts ← Map<family, CoinModuleLoader>, sync load*ForFamily()
loaders.ts ← 29 families, each a require() per slot
load-all-coins.ts ← registerAllCoins() at app startup
types.ts ← CoinModuleLoader interface (the contract)
Wins
Metric
Before
After
CI desktop unit tests
~18 min
~12 min
LLD boot time (M1)
~1.4 s
~1.0 s
Call-site changes
—
0
Mobile bundle was initially exciting: CI reported 56.4 MB → 50.2 MB (−6 MB).
That turned out to be a false positive — mobile wasn't wiring up coin modules the same way, so the old path simply wasn't importing them properly. Not a real gain.
4 — The Bad: require() is CJS in an ESM world
require() inside functions is opaque to bundlers. It breaks scope hoisting (module concatenation, tree-shaking across module boundaries).
The +2.7 MB desktop bundle regression — root cause analysis:
rspack can hoist and tree-shake a static import:
import aleoTx from "@ledgerhq/coin-aleo/transaction";
→ inlined, dead-code eliminated, wrapper stripped
rspack treats require() inside a lambda as a black box:
loadTransaction: () => require("@ledgerhq/coin-aleo/transaction").default
→ emits CJS wrapper, no hoisting, no elimination
Biggest offenders pulled in as CJS instead of ESM:
Package
Added
Root cause
@hashgraph/sdk
~470 KB
.cjs files instead of ESM
ethers/lib.commonjs/*
~260 KB
CJS path selected over lib.esm/
viem/_cjs/*
~150 KB
CJS path instead of _esm/
@aptos-labs/ts-sdk
~149 KB
New ESM chunk
Coin setup.js files
~400 KB
stacks, sui, ton, filecoin, icp, …
We explored a custom rspack loader to transform the compiled loaders.js back to static ESM imports at bundle time — it worked, but added build complexity we didn't want to maintain.
The deeper problem — ESM × CJS two states:
With require() in live-common, live-env (and potentially other shared singletons) could end up instantiated twice — once through the CJS module cache, once through the ESM module graph. Two states of the same module in memory. No apparent bug yet, but a ticking time bomb for subtle, hard-to-diagnose issues.
5 — Mobile: the async chunk surprise (PR #16408, April 16)
The first real dynamic import() we introduced in live-common was getAlpacaApi (PR #16333).
What happened: rspack (Re-Pack) immediately emitted an async chunk for it — a separate .js file expected to be loaded from a remote chunk server at runtime.
Mobile has no ScriptManager configured and no chunk server. The app crashed at runtime trying to fetch a chunk that didn't exist:
Fix (PR #16408): set output.asyncChunks: false in the mobile rspack config.
// apps/ledger-live-mobile/metro.config.js (rspack)
+ output: {+ asyncChunks: false, // mobile has no ScriptManager; disable async chunk emission+ },
This is mobile-specific policy — desktop can and should use async chunks from those same imports. The fix belongs at the app layer, not in live-common.
6 — Mobile Infrastructure: Kevin's Re-Pack lazyImports (PR #16501, April 20)
While the runtime chunk issue was blocked, Kevin (KVNLS) activated lazyImports in the Re-Pack Babel/SWC loader (LIVE-29415):
This tells the Re-Pack bundler to treat import() calls as truly deferred at the Hermes bytecode level — modules are not parsed or evaluated until first access. It's the mobile equivalent of what rspack's code splitting does on desktop.
Performance impact at this stage was unclear (hard to isolate from other changes), but the infrastructure is now ready to see real gains once the coin module loaders flip to async import().
7 — Securing the Boundary (PRs #16470 + #16508, April 21)
While making the call chain async, we needed to ensure no one accidentally re-introduced coin-specific imports into generic code.
Added no-coin-eager-imports.test.ts — a bundle-level check using esbuild's metafile:
// non-regression-entry.ts — every shared surface that touches the registryexport{isAccountEmpty,clearAccount,getVotesCount}from"../account/helpers";export{isEditableOperation,isStuckOperation}from"../operation";export{getCurrencyBridge,getAccountBridge}from"../bridge/impl";export{fromTransactionRaw}from"../transaction/index";export{getDeviceTransactionConfig}from"../transaction/deviceTransactionConfig";export{accountToWalletAPIAccount}from"../wallet-api/converters";// …
The test builds this entry point and asserts no @ledgerhq/coin-* package appears in the bundle output, against an explicit allowlist. The allowlist must shrink to [].
Remove all direct coin-* imports from shared code (PR #16470)
Swept every @ledgerhq/coin-* import outside src/coin-modules/ and src/families/ — routing all of them through the registry:
// account/helpers.ts — before
- import { isAccountEmpty } from "@ledgerhq/coin-cosmos/helpers";- import { isAccountEmpty } from "@ledgerhq/coin-tron/index";
// account/helpers.ts — after
+ const fn = loadIsAccountEmptyForFamily(account.currency.family);+ if (fn) return fn(account);
Side effect: wallet-cli no longer needs the Tron protobuf polyfill — coin-tron is simply not in its bundle anymore.
A Copilot rule was added to .github/copilot-instructions.md to enforce the boundary on future contributions:
Runtime imports of coin-specific code — from @ledgerhq/coin-* or from ../families/* — are only allowed inside src/coin-modules/ and src/families/.
8 — The Async Call Chain (merged, April 20–24)
With the boundary secured, we progressively added await to every call site up the chain — before the functions even became async. await on a synchronous value is a TypeScript-valid no-op, so this prepared all call sites for the flip with zero runtime risk.
hw/actions inferCommandParams, getAddress, signMessage made async
Apr 24
Pattern repeated across all of these:
// Before — synchronous, call site unaware
- const bridge = getAccountBridge(account, parentAccount);- const tx = bridge.createTransaction(account);
// After — await added, no behaviour change today
+ const bridge = await getAccountBridge(account, parentAccount);+ const tx = bridge.createTransaction(account);
9 — The Hard Part: useBridgeTransaction + Suspense
useBridgeTransaction is used in ~107 files across LLD and LLM (Send, Swap, Earn, staking flows). It calls getAccountBridge synchronously in a reducer initialiser. This is the last major blocker for making the bridge loader truly async.
Problem: using React.use(Promise) in the hook caused cascading Suspense on every screen mount — the UI would blank-flash every time you navigated to a Send screen. Unacceptable UX.
The key insight: we don't want to suspend on every mount — only on the very first access of a coin family. Solution: wrap the sync result in a pre-annotated fulfilled Promise that React's use() can read synchronously without triggering a Suspense boundary.
// useAccountBridge — returns synchronously today, suspends naturally later
- const bridge = getAccountBridge(account, parentAccount);+ const bridge = useAccountBridge(account, parentAccount);
// ↑ wraps result in pre-resolved Promise; use() reads it sync
// when getAccountBridge becomes truly async, annotation is removed
// and Suspense fires only on first-ever family load, not every mount
// useBridgeTransaction — initialises state synchronously via useReducer lazy init
- const { transaction } = useBridgeTransaction(() => ({ account, parentAccount }));+ const bridge = useAccountBridge(account, parentAccount);+ const { transaction } = useBridgeTransaction(bridge, () => ({ account, parentAccount }));
// ↑ bridge is now an explicit argument
When getAccountBridge becomes truly async, removing the pre-annotation is the only change needed — all ~107 call sites are already migrated.
After shipping the async call chain work, the ESM × CJS two-states risk became the deciding factor.
The concern:
With require() in loaders.ts, live-env and other shared singletons can end up with two instances in memory — one loaded through the CJS module cache, one through the ESM path. No visible bug today, but any code that does singleton === otherSingleton or holds module-level state would silently misbehave.
"i'm afraid it means we move back from Desktop Unit Tests 12min → 18min like 2 weeks ago but don't worry @lewis.phillips-ext, we're not too far to come back with a 18min → 6min proposal"
Decision: revert loaders.ts to top-level static import.
Keep the CoinModuleLoader registry and interface 100% intact.
Mock bridges stay on require() (test-only, static import would pull mockHelpers → bridge/impl → generic-alpaca before jest.mock() intercepts — breaking test isolation).
ESM × CJS two-states risk
eliminated
Bundle size
back to baseline
CI test time
~18 min temporarily
Registry architecture
fully preserved
Foundation for async import()
clean
11 — Architecture Decision (ADR)
Three decisions locked in:
1. Single unified loading pattern — all coin-specific code routes through CoinModuleLoader. No @ledgerhq/coin-* import in generic/shared code, ever.
2. loaders.ts in live-common short-term; app-level long-term — wallet-cli already registers only 3 families, injecting its own loader config. This is the template for per-app flexibility (replaces setSupportedCurrencies()).
3. CI non-regression enforcement — the bundle check on non-regression-entry.ts must stay green. The allowlist must shrink to [].
12 — Where We Are Now
✅ Barrel files replaced by CoinModuleLoader registry
✅ All direct coin-* imports removed from shared live-common code
✅ Non-regression test suite in CI (allowlist shrinking)
✅ Async call chain prepared (account/tx serialization, bridge, wallet-api, platform, device-tx, hw/actions)
✅ Mobile RePack lazyImports activated (Kevin, LIVE-29415)
✅ Mobile asyncChunks:false guard (no runtime crash on first import())
✅ useBridgeTransaction Suspense-ready pattern found (PR #16726)
⏳ PR #16795 (rollback require() → static import) — open for review
⏳ PR #16726 (useBridgeTransaction + useAccountBridge) — open
⏳ PR #16752 (isEditableOperation/isStuckOperation async) — draft
⏳ PR #16755 (isAccountEmpty/clearAccount/getVotesCount async) — draft
⏳ LIVE-29186 — getAccountBridge/getCurrencyBridge fully async
13 — What's Next: The Final Flip
Once PR #16795 + the remaining async PRs land, loaders.ts gets one targeted change:
rspack (desktop) emits separate async chunks per family.
Re-Pack (mobile) with lazyImports: true defers Hermes bytecode parsing per family.
App startup
└── registerAllCoins() — registers lazy references only, zero coin code loaded
User opens Send on a Bitcoin account (first time)
└── getAccountBridge(btcAccount)
└── await loadBridgeForFamily("bitcoin")
└── import("@ledgerhq/coin-bitcoin/bridge.js") — chunk fetched NOW
└── bridge returned, cached, Suspense resolves
└── all subsequent Bitcoin calls: instant (cache hit)
User never opens a Solana account
└── @ledgerhq/coin-solana → never loaded, never parsed, never in memory
LIVE-29648 — drop CJS builds, live-common ESM-only
Long term: move loaders.ts to app level, each app ships only its families
Q&A Prep
Q: We shipped the require() registry, then we reverted it — what did we actually gain?
The registry architecture is permanent and is the critical piece. The require() loading mechanism was always transitional. We now have: a clean loader interface, non-regression CI enforcement, all coin-* imports removed from generic code, and the entire async call chain prepared. We gave back the 12-min CI temporarily; we get back 6-min (and more) when the final flip lands.
Q: Why not just tree-shake the barrel files?
Tree-shaking removes unused exports. But coin families have module-level side-effects (codec registrations, network client init). Tree-shakers conservatively keep everything. Code-splitting via import() is the only reliable mechanism.
Q: What does the Suspense change mean for users?
On first access of a coin family: ~one paint cycle of loading state (spinner or skeleton). After that: instant, cached. For users who only hold Bitcoin, every other coin never loads at all.
Q: What's the risk of the Suspense activation?
PR #16726 mitigates the biggest risk (cascading blank-screen flash on mount) by using pre-annotated fulfilled Promises. Suspense only fires on genuine first loads of new families, not on every navigation.
Q: What about mobile — will this work with Hermes?
Kevin's lazyImports: true in Re-Pack is exactly the infrastructure needed. asyncChunks: false is already in place to avoid the runtime chunk crash. The mobile path is ready.
Q: What about wallet-cli?
Already ships only 3 families via its own loader config. The long-term app-level loaders.ts pattern is already proven there.
Initiative — History, Discoveries & What's Next · April 2026
Epic: LIVE-29176 · ADR: Coin Module Loading Strategy
1 — The Problem
Every Ledger Live app imports all ~29 coin families at startup through generated barrel files in
libs/ledger-live-common/src/generated/.~20 MB of compressed JS parsed and executed before the app is interactive, for every user, every boot — regardless of which coins they hold.
Twelve files like this. One per domain (bridge, transaction, serialization, …).
Baseline:
T-load)2 — Exploration 1: Go straight to
async import()(PR #16002, Apr 2026)The right fix is obvious: replace every barrel export with a dynamic
import().We explored this fully. First measurements were very promising:
On desktop unit tests (local Mac): 8 min → 3 min.
Bundle size: −5 MB LLD, −6 MB LLM (coin code absent from main chunk).
The problem:
getAccountBridgeandgetCurrencyBridgeare synchronous and used everywhere.Making them async propagates through 267 files across LLD, LLM, live-common, live-wallet:
Too much blast radius for a single PR. We needed a staged approach.
3 — Phase 1: The
require()Registry (PR #16031, merged April 9 2026)Plan: replace the 12 barrel files with a
require()-based registry.require()inside a function is lazy in Node.js — the module loads only on first call.Key benefit: public API stays 100% synchronous — zero call-site changes.
Registry structure:
Wins
Mobile bundle was initially exciting: CI reported 56.4 MB → 50.2 MB (−6 MB).
That turned out to be a false positive — mobile wasn't wiring up coin modules the same way, so the old path simply wasn't importing them properly. Not a real gain.
4 — The Bad:
require()is CJS in an ESM worldrequire()inside functions is opaque to bundlers. It breaks scope hoisting (module concatenation, tree-shaking across module boundaries).The +2.7 MB desktop bundle regression — root cause analysis:
Biggest offenders pulled in as CJS instead of ESM:
@hashgraph/sdk.cjsfiles instead of ESMethers/lib.commonjs/*lib.esm/viem/_cjs/*_esm/@aptos-labs/ts-sdksetup.jsfilesWe explored a custom rspack loader to transform the compiled
loaders.jsback to static ESM imports at bundle time — it worked, but added build complexity we didn't want to maintain.The deeper problem — ESM × CJS two states:
With
require()in live-common,live-env(and potentially other shared singletons) could end up instantiated twice — once through the CJS module cache, once through the ESM module graph. Two states of the same module in memory. No apparent bug yet, but a ticking time bomb for subtle, hard-to-diagnose issues.5 — Mobile: the async chunk surprise (PR #16408, April 16)
The first real
dynamic import()we introduced in live-common wasgetAlpacaApi(PR #16333).What happened: rspack (Re-Pack) immediately emitted an async chunk for it — a separate
.jsfile expected to be loaded from a remote chunk server at runtime.Mobile has no
ScriptManagerconfigured and no chunk server. The app crashed at runtime trying to fetch a chunk that didn't exist:Fix (PR #16408): set
output.asyncChunks: falsein the mobile rspack config.This is mobile-specific policy — desktop can and should use async chunks from those same imports. The fix belongs at the app layer, not in live-common.
6 — Mobile Infrastructure: Kevin's Re-Pack
lazyImports(PR #16501, April 20)While the runtime chunk issue was blocked, Kevin (KVNLS) activated
lazyImportsin the Re-Pack Babel/SWC loader (LIVE-29415):// re-pack babel loader config + lazyImports: trueThis tells the Re-Pack bundler to treat
import()calls as truly deferred at the Hermes bytecode level — modules are not parsed or evaluated until first access. It's the mobile equivalent of what rspack's code splitting does on desktop.Performance impact at this stage was unclear (hard to isolate from other changes), but the infrastructure is now ready to see real gains once the coin module loaders flip to
async import().7 — Securing the Boundary (PRs #16470 + #16508, April 21)
While making the call chain async, we needed to ensure no one accidentally re-introduced coin-specific imports into generic code.
Non-regression test suite (PR #16508)
Added
no-coin-eager-imports.test.ts— a bundle-level check using esbuild's metafile:The test builds this entry point and asserts no
@ledgerhq/coin-*package appears in the bundle output, against an explicit allowlist. The allowlist must shrink to[].Remove all direct coin-* imports from shared code (PR #16470)
Swept every
@ledgerhq/coin-*import outsidesrc/coin-modules/andsrc/families/— routing all of them through the registry:Side effect:
wallet-clino longer needs the Tron protobuf polyfill —coin-tronis simply not in its bundle anymore.A Copilot rule was added to
.github/copilot-instructions.mdto enforce the boundary on future contributions:8 — The Async Call Chain (merged, April 20–24)
With the boundary secured, we progressively added
awaitto every call site up the chain — before the functions even became async.awaiton a synchronous value is a TypeScript-valid no-op, so this prepared all call sites for the flip with zero runtime risk.Series of PRs (all merged):
fromTransactionRaw, account serialization, sign chainprepareMessageToSigngetAlpacaApi→ first realasync import()in live-commongetAccountBridge,getCurrencyBridgecall sites addawaitlogic.tsproperlyawaitbridge + convertersgetDeviceTransactionConfigproperlyawaits its loaderhw/actions inferCommandParams,getAddress,signMessagemade asyncPattern repeated across all of these:
9 — The Hard Part:
useBridgeTransaction+ SuspenseuseBridgeTransactionis used in ~107 files across LLD and LLM (Send, Swap, Earn, staking flows). It callsgetAccountBridgesynchronously in a reducer initialiser. This is the last major blocker for making the bridge loader truly async.Attempt 1 — React
use()+ Suspense (PR #16602, closed)Problem: using
React.use(Promise)in the hook caused cascading Suspense on every screen mount — the UI would blank-flash every time you navigated to a Send screen. Unacceptable UX.Attempt 2 — Pre-annotated fulfilled Promise (PR #16726, open)
The key insight: we don't want to suspend on every mount — only on the very first access of a coin family. Solution: wrap the sync result in a pre-annotated fulfilled Promise that React's
use()can read synchronously without triggering a Suspense boundary.When
getAccountBridgebecomes truly async, removing the pre-annotation is the only change needed — all ~107 call sites are already migrated.10 — The Rollback Decision (PR #16795, open)
After shipping the async call chain work, the ESM × CJS two-states risk became the deciding factor.
The concern:
With
require()inloaders.ts,live-envand other shared singletons can end up with two instances in memory — one loaded through the CJS module cache, one through the ESM path. No visible bug today, but any code that doessingleton === otherSingletonor holds module-level state would silently misbehave.Decision: revert
loaders.tsto top-level staticimport.Keep the
CoinModuleLoaderregistry and interface 100% intact.Mock bridges stay on
require()(test-only, static import would pullmockHelpers → bridge/impl → generic-alpacabeforejest.mock()intercepts — breaking test isolation).async import()11 — Architecture Decision (ADR)
Three decisions locked in:
1. Single unified loading pattern — all coin-specific code routes through
CoinModuleLoader. No@ledgerhq/coin-*import in generic/shared code, ever.2.
loaders.tsin live-common short-term; app-level long-term —wallet-clialready registers only 3 families, injecting its own loader config. This is the template for per-app flexibility (replacessetSupportedCurrencies()).3. CI non-regression enforcement — the bundle check on
non-regression-entry.tsmust stay green. The allowlist must shrink to[].12 — Where We Are Now
13 — What's Next: The Final Flip
Once PR #16795 + the remaining async PRs land,
loaders.tsgets one targeted change:{ family: "bitcoin", - loadBridge: () => bitcoinBridge, // static import — in main bundle + loadBridge: () => import("@ledgerhq/coin-bitcoin/bridge.js"), // async chunk - loadTransaction: () => bitcoinTransaction, + loadTransaction: () => import("@ledgerhq/coin-bitcoin/transaction.js").then(m => m.default), }rspack (desktop) emits separate async chunks per family.
Re-Pack (mobile) with
lazyImports: truedefers Hermes bytecode parsing per family.Projected final metrics
14 — Remaining Work
Unlock the flip
useBridgeTransaction+useAccountBridge(Suspense-ready, ~107 files)isEditableOperation/isStuckOperationasyncisAccountEmpty/clearAccount/getVotesCountasyncgetAccountBridge/getCurrencyBridgefully async[]After the flip
renderer/families/UI modulesloaders.tsto app level, each app ships only its familiesQ&A Prep
Q: We shipped the require() registry, then we reverted it — what did we actually gain?
The registry architecture is permanent and is the critical piece. The
require()loading mechanism was always transitional. We now have: a clean loader interface, non-regression CI enforcement, all coin-* imports removed from generic code, and the entire async call chain prepared. We gave back the 12-min CI temporarily; we get back 6-min (and more) when the final flip lands.Q: Why not just tree-shake the barrel files?
Tree-shaking removes unused exports. But coin families have module-level side-effects (codec registrations, network client init). Tree-shakers conservatively keep everything. Code-splitting via
import()is the only reliable mechanism.Q: What does the Suspense change mean for users?
On first access of a coin family: ~one paint cycle of loading state (spinner or skeleton). After that: instant, cached. For users who only hold Bitcoin, every other coin never loads at all.
Q: What's the risk of the Suspense activation?
PR #16726 mitigates the biggest risk (cascading blank-screen flash on mount) by using pre-annotated fulfilled Promises. Suspense only fires on genuine first loads of new families, not on every navigation.
Q: What about mobile — will this work with Hermes?
Kevin's
lazyImports: truein Re-Pack is exactly the infrastructure needed.asyncChunks: falseis already in place to avoid the runtime chunk crash. The mobile path is ready.Q: What about wallet-cli?
Already ships only 3 families via its own loader config. The long-term app-level
loaders.tspattern is already proven there.Timeline
References