Skip to content

refactor: Lazy-load MWP packages + eciesjs/KeyManager behind dynamic import()#244

Open
ffmcgee725 wants to merge 4 commits intomainfrom
jc/WAPI-1270
Open

refactor: Lazy-load MWP packages + eciesjs/KeyManager behind dynamic import()#244
ffmcgee725 wants to merge 4 commits intomainfrom
jc/WAPI-1270

Conversation

@ffmcgee725
Copy link
Copy Markdown
Member

Explanation

Converts static imports of @metamask/mobile-wallet-protocol-core, @metamask/mobile-wallet-protocol-dapp-client, and eciesjs to dynamic import() calls so the entire MWP + crypto dependency tree is only loaded when MWP transport (QR code / deeplink pairing with MetaMask Mobile) is actually used. Desktop browser extension users never hit this code path and will no longer pay the load-time cost.

Transitive dependencies now deferred

Package Brings in
mwp-core centrifugeprotobufjs (12+ sub-packages incl. @types/node), async-mutex, events
eciesjs @ecies/ciphers, @noble/ciphers (3 unique packages; @noble/curves / @noble/hashes shared with mwp-core)

What changed

packages/connect-multichain/src/multichain/index.ts

  • Removed top-level value imports of ErrorCode, ProtocolError, SessionStore, WebSocketTransport (mwp-core), DappClient (mwp-dapp-client), MWPTransport (local transport), and keymanager (KeyManager).
  • Kept / added type-only imports for SessionRequest and DappClient (erased at compile time).
  • #createDappClient() now dynamically imports all three MWP packages in parallel via Promise.all, then calls the new createKeyManager() async factory.
  • Added #mwpModules class field — caches ProtocolError and ErrorCode from mwp-core after the first lazy load so error instanceof ProtocolError and ErrorCode.REQUEST_EXPIRED checks in catch blocks continue to work correctly.
  • Added #transportType class field — replaces all instanceof MWPTransport checks with a tracked TransportType enum, eliminating the need for a static import of the MWPTransport class.

packages/connect-multichain/src/multichain/transports/mwp/KeyManager.ts

  • Converted from a class with a static eciesjs import + exported singleton to an async factory function (createKeyManager()) that dynamically imports eciesjs and returns an IKeyManager via closure. This prevents esbuild from hoisting the eciesjs external import to the bundle's top level.

packages/connect-multichain/src/multichain/transports/mwp/index.ts

  • Converted the static import { SessionStore } from mwp-core to a dynamic import() inside getActiveSession(). This was the last remaining static import that esbuild was hoisting to the top level.

Bundle size analysis

Format Before After Delta
browser/es (ESM) 138,433 B 145,724 B +7,291 B (+5.3%)
browser/umd 141,811 B 148,868 B +7,057 B (+5.0%)
browser/iife 1,592,709 B 1,631,104 B +38,395 B (+2.4%)
node/cjs 139,590 B 146,647 B +7,057 B (+5.1%)
node/es 136,008 B 143,299 B +7,291 B (+5.4%)
react-native/es 134,320 B 141,953 B +7,633 B (+5.7%)

The package's own bundle increases ~5–7 KB due to esbuild's async module init wrappers (__esm blocks). This is expected because MWP packages are currently bundled (not external) with splitting: false.

The key improvement is for downstream consumers: the ESM output now contains zero top-level static imports of mwp-core, mwp-dapp-client, or eciesjs. A consumer's bundler (webpack, Vite, etc.) can code-split the entire MWP + crypto tree into a lazy chunk that is never loaded for extension-only users.

Follow-up opportunity: adding MWP packages to the external list in tsup.config.ts or enabling splitting: true for ESM builds would let the package's own build benefit from the split as well.

Test plan

  • All 306 existing tests pass (4 pre-existing skips)
  • TypeScript build succeeds with full type checking
  • No linter errors introduced
  • error instanceof ProtocolError checks still work correctly via cached #mwpModules
  • Verified ESM build output has no top-level static imports of MWP packages or eciesjs

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

@ffmcgee725 ffmcgee725 requested a review from a team as a code owner March 27, 2026 10:47
return this.#transport instanceof MWPTransport
? TransportType.MWP
: TransportType.Browser;
return this.#transportType ?? TransportType.Browser;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we just invert the condition to this.#transport instanceof DefaultTransport instead? I'm not too keen on having manage the this.#transportType value if we don't have to

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing here is #transportType is used in too many places where instanceof won't work

Examples of this would be

  • Reading the stored transport type before any instance exists, the public transportType getter that feeds into RequestRouter/analytics,
  • the #setupMWP early return (which would force an eager import and break lazy-loading
  • the disconnect logic where the transport may already be partially torn down

This isn't just tracking what the transport is, it's tracking what it should be in states where the instance doesn't exist yet. The one spot where it is redundant is the #setupDefaultTransport guard, but we removed this in b645322

*
* @returns A ready-to-use key manager instance.
*/
export async function createKeyManager(): Promise<IKeyManager> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like how you were able to encapsulate the dynamic importing complexity entirely into this file

await Promise.all([
import('@metamask/mobile-wallet-protocol-core'),
import('@metamask/mobile-wallet-protocol-dapp-client'),
import('./transports/mwp/KeyManager'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does KeyManager need to be lazy loaded?

Copy link
Copy Markdown
Member Author

@ffmcgee725 ffmcgee725 Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

according to ticket spec, yes, it's intentional

Comment on lines +490 to +615
@@ -584,7 +609,10 @@ export class MetaMaskConnectMultichain extends MultichainCore {
resolve();
})
.catch(async (error) => {
if (error instanceof ProtocolError) {
if (
this.#mwpModules &&
error instanceof this.#mwpModules.ProtocolError
) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on just lazy import mwp-core again in here to get access to the error classes rather than setting the error classes on the #mwpModules property on the MultichainClient instance?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to me
5065514

Comment on lines +404 to +407
const { MWPTransport: MWPTransportClass } = await import(
'./transports/mwp'
);
const apiTransport = new MWPTransportClass(dappClient, kvstore);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { MWPTransport: MWPTransportClass } = await import(
'./transports/mwp'
);
const apiTransport = new MWPTransportClass(dappClient, kvstore);
const { MWPTransport } = await import(
'./transports/mwp'
);
const apiTransport = new MWPTransport(dappClient, kvstore);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +634 to +635
this.#transportType === TransportType.Browser &&
this.#transport instanceof DefaultTransport
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the double check here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants