@solana/kit-plugin-wallet 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +102 -4
  2. package/dist/index.browser.cjs +120 -36
  3. package/dist/index.browser.cjs.map +1 -1
  4. package/dist/index.browser.mjs +120 -37
  5. package/dist/index.browser.mjs.map +1 -1
  6. package/dist/index.node.cjs +9 -2
  7. package/dist/index.node.cjs.map +1 -1
  8. package/dist/index.node.mjs +9 -3
  9. package/dist/index.node.mjs.map +1 -1
  10. package/dist/index.react-native.mjs +9 -3
  11. package/dist/index.react-native.mjs.map +1 -1
  12. package/dist/react/index.browser.cjs +77 -0
  13. package/dist/react/index.browser.cjs.map +1 -0
  14. package/dist/react/index.browser.mjs +66 -0
  15. package/dist/react/index.browser.mjs.map +1 -0
  16. package/dist/react/index.node.cjs +77 -0
  17. package/dist/react/index.node.cjs.map +1 -0
  18. package/dist/react/index.node.mjs +66 -0
  19. package/dist/react/index.node.mjs.map +1 -0
  20. package/dist/react/index.react-native.mjs +66 -0
  21. package/dist/react/index.react-native.mjs.map +1 -0
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/dist/types/react/index.d.ts +235 -0
  25. package/dist/types/react/index.d.ts.map +1 -0
  26. package/dist/types/status.d.ts +31 -0
  27. package/dist/types/status.d.ts.map +1 -0
  28. package/dist/types/store.d.ts.map +1 -1
  29. package/dist/types/types.d.ts +57 -9
  30. package/dist/types/types.d.ts.map +1 -1
  31. package/dist/types/wallet.d.ts +3 -3
  32. package/dist/types/wallet.d.ts.map +1 -1
  33. package/package.json +45 -14
  34. package/src/__typetests__/wallet-typetest.ts +15 -0
  35. package/src/index.ts +1 -0
  36. package/src/react/__typetests__/index.ts +108 -0
  37. package/src/react/index.ts +301 -0
  38. package/src/status.ts +33 -0
  39. package/src/store.ts +208 -81
  40. package/src/types.ts +58 -9
  41. package/src/wallet.ts +3 -1
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ReadonlyUint8Array,
2
3
  type SignatureBytes,
3
4
  SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE,
4
5
  SOLANA_ERROR__WALLET__NOT_CONNECTED,
@@ -26,6 +27,7 @@ import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui';
26
27
  import { getWalletAccountFeature, getWalletFeature } from '@wallet-standard/ui-features';
27
28
  import { getOrCreateUiWalletForStandardWallet, getWalletForHandle } from '@wallet-standard/ui-registry';
28
29
 
30
+ import { isWalletWarmingUp } from './status';
29
31
  import type {
30
32
  WalletActionOptions,
31
33
  WalletNamespace,
@@ -85,6 +87,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
85
87
  Promise.reject(new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'signMessage' })),
86
88
  subscribe: () => () => {},
87
89
  subscribeSigner: () => () => {},
90
+ whenReady: () => Promise.resolve(),
88
91
  [Symbol.dispose]: () => {},
89
92
  };
90
93
  }
@@ -101,13 +104,22 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
101
104
 
102
105
  const listeners = new Set<() => void>();
103
106
  const signerListeners = new Set<() => void>();
104
- let walletEventsCleanup: (() => void) | null = null;
107
+ // One `standard:events change` subscription per discovered wallet, keyed by
108
+ // name (the stable identity; the UiWallet handle regenerates on changes). The
109
+ // underlying subscription attaches to the raw wallet's events, so it survives
110
+ // handle regeneration — no need to resubscribe when a wallet's accounts change.
111
+ const walletEventSubscriptions = new Map<string, () => void>();
105
112
  let reconnectCleanup: (() => void) | null = null;
106
113
  let userHasSelected = false;
107
114
  let connectGeneration = 0;
108
115
  let disposed = false;
109
116
  // Wallet that is in the process of disconnecting, stored to guard against re-connecting to it if a future connect fails
110
117
  let disconnectingWalletName: string | null = null;
118
+ // Deferred backing `whenReady`. Created lazily while the status is transient
119
+ // (see `isWalletWarmingUp`) and resolved by `updateState` once the status
120
+ // settles, so `whenReady` can hand out a stable promise for the duration of
121
+ // one warm-up episode (required by React Suspense).
122
+ let readyDeferred: PromiseWithResolvers<void> | null = null;
111
123
 
112
124
  // Resolve storage: default to localStorage in browser, null to disable.
113
125
  // Merely *accessing* `localStorage` throws a `SecurityError` in sandboxed
@@ -155,6 +167,19 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
155
167
  const prev = state;
156
168
  state = { ...state, ...updates };
157
169
 
170
+ // Resolve `whenReady` the moment the status leaves the transient
171
+ // warm-up set. `readyDeferred` is only ever created while transient (in
172
+ // `whenReady`), so clearing it here lets the next transient episode arm
173
+ // a fresh one.
174
+ if (isWalletWarmingUp(prev.status) && !isWalletWarmingUp(state.status)) {
175
+ readyDeferred?.resolve();
176
+ readyDeferred = null;
177
+ }
178
+
179
+ if (state.wallets !== prev.wallets) {
180
+ syncWalletEventSubscriptions(state.wallets);
181
+ }
182
+
158
183
  // Signer subscribers (`subscribeToPayer` / `subscribeToIdentity`) fire
159
184
  // only when the observed signer changes.
160
185
  const signerChanged = observableSigner(state) !== observableSigner(prev);
@@ -175,6 +200,18 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
175
200
  }
176
201
  }
177
202
 
203
+ // Advanced: see the `whenReady` docblock on `WalletNamespace`. Returns a
204
+ // stable, already-resolved promise once the initial warm-up has settled;
205
+ // while transient, returns the same pending promise on every call so it is
206
+ // safe to throw for React Suspense.
207
+ function whenReady(): Promise<void> {
208
+ if (!isWalletWarmingUp(state.status)) {
209
+ return Promise.resolve();
210
+ }
211
+ readyDeferred ??= Promise.withResolvers<void>();
212
+ return readyDeferred.promise;
213
+ }
214
+
178
215
  // -- Signer creation ---------------------------------------------------
179
216
 
180
217
  function tryCreateSigner(account: UiWalletAccount): WalletSigner | null {
@@ -227,6 +264,29 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
227
264
  return next;
228
265
  }
229
266
 
267
+ // Reconcile the per-wallet event subscriptions against the current wallet set:
268
+ // subscribe wallets that just appeared, unsubscribe those that left. Called
269
+ // whenever `state.wallets` changes reference. Subscribing fires nothing, so
270
+ // this never causes re-render churn. `wallets` is the already-filtered visible
271
+ // list, so wallets excluded by chain/feature/`config.filter` are intentionally
272
+ // never subscribed — a `register` / `unregister` event reconciles them in or
273
+ // out of the list (and thus in or out of this map) when their eligibility
274
+ // changes.
275
+ function syncWalletEventSubscriptions(wallets: readonly UiWallet[]): void {
276
+ const names = new Set(wallets.map(w => w.name));
277
+ for (const [name, cleanup] of walletEventSubscriptions) {
278
+ if (!names.has(name)) {
279
+ cleanup();
280
+ walletEventSubscriptions.delete(name);
281
+ }
282
+ }
283
+ for (const w of wallets) {
284
+ if (!walletEventSubscriptions.has(w.name)) {
285
+ walletEventSubscriptions.set(w.name, subscribeToWalletEvents(w));
286
+ }
287
+ }
288
+ }
289
+
230
290
  // True if a wallet matching `uiWallet` (by name) is currently registered and
231
291
  // passes the filter. Used to re-check membership after an awaited connect /
232
292
  // sign-in / silent reconnect resolves: the wallet may have unregistered (or
@@ -253,8 +313,6 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
253
313
 
254
314
  // If the connected wallet was unregistered, disconnect locally.
255
315
  if (state.connectedWallet && !newWallets.some(w => w.name === state.connectedWallet!.name)) {
256
- walletEventsCleanup?.();
257
- walletEventsCleanup = null;
258
316
  updates.connectedWallet = null;
259
317
  updates.account = null;
260
318
  updates.signer = null;
@@ -272,11 +330,6 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
272
330
  reconnectCleanup = null;
273
331
  }
274
332
 
275
- function resubscribeToWalletEvents(uiWallet: UiWallet): void {
276
- walletEventsCleanup?.();
277
- walletEventsCleanup = subscribeToWalletEvents(uiWallet);
278
- }
279
-
280
333
  function setConnected(account: UiWalletAccount, wallet: UiWallet, options?: { persist?: boolean }): void {
281
334
  // The store may have been disposed while a connect / sign-in / silent
282
335
  // reconnect awaited. The `connectGeneration` guard at each call site
@@ -287,9 +340,23 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
287
340
  // itself connected.
288
341
  if (disposed) return;
289
342
  const signer = tryCreateSigner(account);
290
- resubscribeToWalletEvents(wallet);
291
343
  disconnectingWalletName = null;
292
- updateState({ account, connectedWallet: wallet, signer, status: 'connected' });
344
+ // Reconcile `wallets` alongside the connection: `connect`/`signIn`/silent
345
+ // reconnect authorize accounts on the underlying wallet, which regenerates
346
+ // its registry handle. Without rebuilding the list here, `getState().wallets`
347
+ // would keep the pre-connect handle (empty `.accounts`) until an unrelated
348
+ // `standard:events change` happened to fire — so consumers reading the
349
+ // just-connected wallet from `wallets` (e.g. rendering its authorized
350
+ // accounts) would see it as unconnected. `reconcileWalletList` returns the
351
+ // existing reference when the visible set is unchanged, keeping the snapshot
352
+ // churn-free when connect didn't actually add accounts.
353
+ updateState({
354
+ account,
355
+ connectedWallet: wallet,
356
+ signer,
357
+ status: 'connected',
358
+ wallets: reconcileWalletList(),
359
+ });
293
360
  if (options?.persist !== false) {
294
361
  persistAccount(account, wallet);
295
362
  }
@@ -304,45 +371,43 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
304
371
  if (!uiWallet.features.includes(StandardEvents)) {
305
372
  return () => {};
306
373
  }
307
-
308
374
  const eventsFeature = getWalletFeature(
309
375
  uiWallet,
310
376
  StandardEvents,
311
377
  ) as StandardEventsFeature[typeof StandardEvents];
312
-
313
- // The handler ignores the changed-property flags (`accounts`, `chains`,
314
- // `features`) and instead reconciles the active account against the
315
- // refreshed wallet. The signer is derived entirely from the account's
316
- // features and chains, and the wallet-ui registry regenerates the
317
- // account reference precisely when one of those changes so the
318
- // account reference is the single source of truth for whether the
319
- // signer is affected, regardless of which flag the wallet sets.
378
+ const walletName = uiWallet.name;
379
+
380
+ // The handler intentionally ignores the changed-property flags
381
+ // (`accounts`, `chains`, `features`) the wallet passes and instead
382
+ // reconciles against the refreshed wallet. The signer is derived
383
+ // entirely from the active account's features and chains, and the
384
+ // wallet-ui registry regenerates the account reference precisely when
385
+ // one of those changes so the account reference, not the flags, is
386
+ // the single source of truth for whether the signer is affected. Branch
387
+ // on the reconciled state below, never on `properties`.
320
388
  return eventsFeature.on('change', () => {
321
- const refreshed = refreshUiWallet(uiWallet);
389
+ // Refresh the discovered list so this wallet's (and any others') accounts
390
+ // are current; `updateState` re-syncs subscriptions for added/removed wallets.
391
+ const newWallets = reconcileWalletList();
392
+
393
+ // Non-active wallet: reflect its new account/feature state in the list only.
394
+ if (!state.connectedWallet || state.connectedWallet.name !== walletName) {
395
+ updateState({ wallets: newWallets });
396
+ return;
397
+ }
322
398
 
323
- // If the wallet no longer passes the filter (e.g. it dropped the
324
- // configured chain or a required feature), disconnect.
399
+ // Active wallet: the original reconcile logic, unchanged.
400
+ const refreshed = refreshUiWallet(state.connectedWallet);
325
401
  if (!filterWallet(refreshed)) {
326
402
  disconnectLocally();
327
403
  return;
328
404
  }
329
-
330
405
  const result = reconcileActiveAccount(refreshed);
331
406
  if (result === null) {
332
407
  disconnectLocally();
333
408
  return;
334
409
  }
335
-
336
- // `connectedWallet` is always refreshed so consumers reading
337
- // `getState().connected.wallet` see current metadata; the signer
338
- // (in `result`) only churns when the active account changed, which
339
- // keeps the snapshot referentially stable on no-op change events.
340
- // `wallets` is reconciled too: the registry regenerates the wallet
341
- // handle on a change, so without this the list entry would keep the
342
- // stale handle (`wallets[i] !== connected.wallet`). `reconcileWalletList`
343
- // returns the existing reference on a no-op, preserving that stability.
344
- updateState({ connectedWallet: refreshed, wallets: reconcileWalletList(), ...result });
345
-
410
+ updateState({ connectedWallet: refreshed, wallets: newWallets, ...result });
346
411
  if (result.account) {
347
412
  persistAccount(result.account, refreshed);
348
413
  }
@@ -441,17 +506,14 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
441
506
  }
442
507
  }
443
508
 
444
- async function disconnect(options?: WalletActionOptions): Promise<void> {
509
+ async function disconnect(wallet?: UiWallet, options?: WalletActionOptions): Promise<void> {
445
510
  options?.abortSignal?.throwIfAborted();
446
511
 
447
- // No connected wallet, but an auto-reconnect may still be in flight:
448
- // during 'reconnecting' a silent reconnect or the late-registration
449
- // listener can complete and connect, overriding this explicit
450
- // disconnect. Record the user's intent so auto-connect won't re-arm,
451
- // tear down the late-registration listener, and bump the generation so
452
- // any in-flight connect/signIn/silent-reconnect bails at its guard
453
- // instead of resuming.
454
- if (!state.connectedWallet) {
512
+ const target = wallet ?? state.connectedWallet;
513
+
514
+ // Nothing to disconnect (no arg, nothing active): cancel any pending reconnect, and
515
+ // supersede in-flight attempts.
516
+ if (!target) {
455
517
  userHasSelected = true;
456
518
  cancelReconnect();
457
519
  connectGeneration++;
@@ -460,39 +522,68 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
460
522
  return;
461
523
  }
462
524
 
463
- const currentWallet = state.connectedWallet;
464
- // Bump the generation so this disconnect supersedes any connect/signIn
465
- // that was already in flight (its prompt opened before the user chose to
466
- // disconnect): that attempt captured an earlier generation, so it will
467
- // bail at its guard rather than establishing a connection the user has
468
- // since dismissed. Mirrors the not-connected branch above — newest action
469
- // wins. A connect/signIn started *after* this disconnect bumps the
470
- // generation again, so the `finally` guard skips `disconnectLocally` and
471
- // leaves the newer connection intact.
472
- const generation = ++connectGeneration;
473
- // Mark this wallet as disconnecting
474
- disconnectingWalletName = currentWallet.name;
475
- updateState({ status: 'disconnecting' });
525
+ const isActive = state.connectedWallet != null && state.connectedWallet.name === target.name;
476
526
 
477
- try {
478
- if (currentWallet.features.includes(StandardDisconnect)) {
479
- const disconnectFeature = getWalletFeature(
480
- currentWallet,
481
- StandardDisconnect,
482
- ) as StandardDisconnectFeature[typeof StandardDisconnect];
483
- await disconnectFeature.disconnect();
527
+ if (isActive) {
528
+ const currentWallet = state.connectedWallet!;
529
+ // Bump generation so this disconnect supersedes any in-flight connect/signIn.
530
+ const generation = ++connectGeneration;
531
+ disconnectingWalletName = currentWallet.name;
532
+ updateState({ status: 'disconnecting' });
533
+ try {
534
+ if (currentWallet.features.includes(StandardDisconnect)) {
535
+ const disconnectFeature = getWalletFeature(
536
+ currentWallet,
537
+ StandardDisconnect,
538
+ ) as StandardDisconnectFeature[typeof StandardDisconnect];
539
+ await disconnectFeature.disconnect();
540
+ }
541
+ } finally {
542
+ if (generation === connectGeneration) {
543
+ disconnectLocally();
544
+ }
484
545
  }
546
+ return;
547
+ }
548
+
549
+ // targeted disconnect but wallet gone: leave the active connection, status,
550
+ // and persisted key untouched.
551
+ if (!isWalletStillAvailable(target)) {
552
+ return; // already gone / not authorized — forgiving no-op
553
+ }
554
+ const refreshedTarget = refreshUiWallet(target);
555
+ if (!refreshedTarget.features.includes(StandardDisconnect)) {
556
+ return; // can't deauthorize without standard:disconnect — no-op
557
+ }
558
+ // Guard against a concurrent select/connect into this wallet for the await;
559
+ // restore the prior marker afterward (it may belong to an active disconnect).
560
+ const prevDisconnecting = disconnectingWalletName;
561
+ disconnectingWalletName = refreshedTarget.name;
562
+ try {
563
+ const disconnectFeature = getWalletFeature(
564
+ refreshedTarget,
565
+ StandardDisconnect,
566
+ ) as StandardDisconnectFeature[typeof StandardDisconnect];
567
+ await disconnectFeature.disconnect();
485
568
  } finally {
486
- // Only clean up if no new connect/signIn started while we were awaiting.
487
- if (generation === connectGeneration) {
488
- disconnectLocally();
569
+ if (disconnectingWalletName === refreshedTarget.name) {
570
+ disconnectingWalletName = prevDisconnecting;
571
+ }
572
+ // Reflect the now-empty accounts immediately — but only if the store
573
+ // is still live. If it was disposed while `standard:disconnect`
574
+ // awaited, the disposer has already torn down every wallet-event
575
+ // subscription and cleared the map; deauthorizing the target
576
+ // regenerated its handle, so `reconcileWalletList` returns a fresh
577
+ // array, and letting `updateState` run here would re-invoke
578
+ // `syncWalletEventSubscriptions` and re-subscribe every wallet into
579
+ // the cleared map — listeners the disposer can no longer clean up.
580
+ if (!disposed) {
581
+ updateState({ wallets: reconcileWalletList() });
489
582
  }
490
583
  }
491
584
  }
492
585
 
493
586
  function disconnectLocally(): void {
494
- walletEventsCleanup?.();
495
- walletEventsCleanup = null;
496
587
  disconnectingWalletName = null;
497
588
 
498
589
  updateState({
@@ -532,14 +623,34 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
532
623
  }
533
624
 
534
625
  function selectAccount(account: UiWalletAccount): void {
535
- if (!state.connectedWallet) {
536
- throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' });
626
+ // Derive the owning wallet from the account handle. `getWalletForHandle`
627
+ // keys a WeakMap on handle identity, so this is unambiguous even when the
628
+ // same address is authorized in two wallets. A stale/unregistered handle
629
+ // throws — surface it as ACCOUNT_NOT_AVAILABLE.
630
+ let owner: UiWallet;
631
+ try {
632
+ owner = getOrCreateUiWalletForStandardWallet(getWalletForHandle(account));
633
+ } catch {
634
+ throw new SolanaError(SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
635
+ account: account.address,
636
+ operation: 'selectAccount',
637
+ });
638
+ }
639
+
640
+ // The owner must be currently authorized (discovered + passes the filter).
641
+ if (!isWalletStillAvailable(owner)) {
642
+ throw new SolanaError(SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
643
+ account: account.address,
644
+ operation: 'selectAccount',
645
+ });
537
646
  }
538
- // Reject if this wallet is disconnecting
539
- if (state.connectedWallet.name === disconnectingWalletName) {
647
+
648
+ // Reject selecting into a wallet that is mid-disconnect.
649
+ if (owner.name === disconnectingWalletName) {
540
650
  throw new SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: 'selectAccount' });
541
651
  }
542
- const refreshed = refreshUiWallet(state.connectedWallet);
652
+
653
+ const refreshed = refreshUiWallet(owner);
543
654
  const selectedAccount = refreshed.accounts.find(a => a.address === account.address);
544
655
  if (!selectedAccount) {
545
656
  throw new SolanaError(SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
@@ -547,16 +658,20 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
547
658
  operation: 'selectAccount',
548
659
  });
549
660
  }
661
+
550
662
  userHasSelected = true;
551
- // Bump the generation so this selection supersedes any in-flight
552
- // connect/signIn
663
+ // Supersede any in-flight connect/signIn.
553
664
  ++connectGeneration;
554
- // Change status back to `connected` if something in-flight changed it
555
- // But skip recreating the signer if account is the same as before
665
+
666
+ // No-op fast path: the same account is already active.
556
667
  if (selectedAccount === state.account && refreshed === state.connectedWallet) {
557
668
  updateState({ status: 'connected' });
558
669
  return;
559
670
  }
671
+
672
+ // Switch the active connection (owner may differ from the current one). The
673
+ // previously-active wallet is left authorized — we never disconnect it. Its
674
+ // event subscription persists, so switching back stays live.
560
675
  const signer = tryCreateSigner(selectedAccount);
561
676
  updateState({ account: selectedAccount, connectedWallet: refreshed, signer, status: 'connected' });
562
677
  persistAccount(selectedAccount, refreshed);
@@ -564,7 +679,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
564
679
 
565
680
  // -- Message signing ---------------------------------------------------
566
681
 
567
- async function signMessage(message: Uint8Array, options?: WalletActionOptions): Promise<SignatureBytes> {
682
+ async function signMessage(message: ReadonlyUint8Array, options?: WalletActionOptions): Promise<SignatureBytes> {
568
683
  options?.abortSignal?.throwIfAborted();
569
684
  const { connectedWallet, account } = state;
570
685
  if (!connectedWallet || !account) {
@@ -581,7 +696,9 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
581
696
  account,
582
697
  SolanaSignMessage,
583
698
  ) as SolanaSignMessageFeature[typeof SolanaSignMessage];
584
- const [output] = await signMessageFeature.signMessage({ account, message });
699
+ // The wallet-standard `SolanaSignMessageInput.message` is a mutable `Uint8Array`; signing
700
+ // only reads the bytes, so coerce the read-only input at this single boundary.
701
+ const [output] = await signMessageFeature.signMessage({ account, message: message as Uint8Array });
585
702
  return output.signature as SignatureBytes;
586
703
  }
587
704
 
@@ -842,6 +959,7 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
842
959
  signerListeners.delete(listener);
843
960
  };
844
961
  },
962
+ whenReady,
845
963
  [Symbol.dispose]: () => {
846
964
  // Invalidate any in-flight connect/signIn/silent-reconnect so they
847
965
  // bail instead of resuming after disposal (which would re-subscribe
@@ -853,11 +971,20 @@ export function createWalletStore(config: WalletPluginConfig): WalletStore {
853
971
  connectGeneration++;
854
972
  unsubRegister();
855
973
  unsubUnregister();
856
- walletEventsCleanup?.();
857
- walletEventsCleanup = null;
974
+ for (const cleanup of walletEventSubscriptions.values()) {
975
+ cleanup();
976
+ }
977
+ walletEventSubscriptions.clear();
858
978
  cancelReconnect();
859
979
  listeners.clear();
860
980
  signerListeners.clear();
981
+ // A store disposed mid-warm-up will never finish warming up, so
982
+ // settle the status to 'disconnected'.
983
+ // Runs after the listener sets are cleared, so no one is notified of
984
+ // the final transition.
985
+ if (isWalletWarmingUp(state.status)) {
986
+ updateState({ status: 'disconnected' });
987
+ }
861
988
  },
862
989
  };
863
990
  }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MessageSigner, SignatureBytes, TransactionSigner } from '@solana/kit';
1
+ import type { MessageSigner, ReadonlyUint8Array, SignatureBytes, TransactionSigner } from '@solana/kit';
2
2
  import type { SolanaChain } from '@solana/wallet-standard-chains';
3
3
  import type { SolanaSignInInput, SolanaSignInOutput } from '@solana/wallet-standard-features';
4
4
  import type { IdentifierString } from '@wallet-standard/base';
@@ -244,13 +244,19 @@ export type WalletNamespace = {
244
244
  connect: (wallet: UiWallet, options?: WalletActionOptions) => Promise<readonly UiWalletAccount[]>;
245
245
 
246
246
  /**
247
- * Disconnect the active wallet. Calls `standard:disconnect` if supported.
247
+ * Disconnect a wallet. Calls `standard:disconnect` if supported.
248
+ *
249
+ * `wallet` defaults to the active wallet. Passing a non-active, currently
250
+ * authorized wallet deauthorizes it (calling `standard:disconnect` if
251
+ * supported) while leaving the active connection untouched. A `wallet`
252
+ * that doesn't support `standard:disconnect`, or one that is already
253
+ * unauthorized/unregistered, is a forgiving no-op.
248
254
  *
249
255
  * @throws `options.abortSignal.reason` if the signal is already aborted
250
256
  * when the action is called. Aborts after the wallet call has been
251
257
  * dispatched do not take effect.
252
258
  */
253
- disconnect: (options?: WalletActionOptions) => Promise<void>;
259
+ disconnect: (wallet?: UiWallet, options?: WalletActionOptions) => Promise<void>;
254
260
 
255
261
  // -- State --
256
262
  /**
@@ -263,13 +269,19 @@ export type WalletNamespace = {
263
269
  getState: () => WalletState;
264
270
 
265
271
  /**
266
- * Switch to a different account within the connected wallet. Creates and
267
- * caches a new signer for the selected account.
272
+ * Select an account, creating and caching a new signer for it.
273
+ *
274
+ * The account may belong to any currently authorized wallet, not only the
275
+ * active one. When it belongs to a different authorized wallet, the active
276
+ * connection switches to that wallet synchronously (no prompt), leaving the
277
+ * previously active wallet authorized. If the store is currently 'disconnected',
278
+ * this allows transitioning to 'connected' synchronously.
268
279
  *
269
- * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if no wallet is
270
- * connected, or the connected wallet is currently disconnecting.
271
280
  * @throws `SolanaError(SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE)` if the
272
- * specified account is not among the connected wallet's accounts.
281
+ * account's handle cannot be resolved to a currently authorized wallet, or
282
+ * the account is not among that wallet's accounts.
283
+ * @throws `SolanaError(SOLANA_ERROR__WALLET__NOT_CONNECTED)` if the account's
284
+ * wallet is currently disconnecting.
273
285
  */
274
286
  selectAccount: (account: UiWalletAccount) => void;
275
287
 
@@ -321,7 +333,7 @@ export type WalletNamespace = {
321
333
  * when the action is called. Aborts after the wallet call has been
322
334
  * dispatched do not take effect.
323
335
  */
324
- signMessage: (message: Uint8Array, options?: WalletActionOptions) => Promise<SignatureBytes>;
336
+ signMessage: (message: ReadonlyUint8Array, options?: WalletActionOptions) => Promise<SignatureBytes>;
325
337
 
326
338
  /**
327
339
  * Subscribe to any wallet state change. Compatible with React's
@@ -335,6 +347,43 @@ export type WalletNamespace = {
335
347
  * ```
336
348
  */
337
349
  subscribe: (listener: () => void) => () => void;
350
+
351
+ /**
352
+ * **Advanced.** Resolves once the initial connection attempt has settled —
353
+ * i.e. {@link getState}'s `status` is no longer `'pending'` or
354
+ * `'reconnecting'`.
355
+ *
356
+ * You only need this if your app **rebuilds the client at runtime** (for
357
+ * example, to change chain). Each freshly built client runs a silent
358
+ * auto-reconnect that briefly passes through `'pending'`/`'reconnecting'`;
359
+ * awaiting this lets you hold the previous UI until the new client is ready
360
+ * instead of flashing a "reconnecting" state on every swap. Most apps never
361
+ * call it — subscribe to `status` with {@link subscribe} instead.
362
+ *
363
+ * Does **not** wait for user-initiated `connect`/`disconnect`/`signIn`
364
+ * (those pass through `'connecting'`/`'disconnecting'`, which count as
365
+ * ready), so a normal connect never makes this block. While transient it
366
+ * returns the **same** promise reference on every call, so successive
367
+ * awaits during one warm-up share a single settlement.
368
+ *
369
+ * Disposing the client mid-warm-up settles the status to `'disconnected'`,
370
+ * so a pending `whenReady()` resolves rather than hanging. Check
371
+ * {@link getState} after awaiting if you need to distinguish a ready
372
+ * connection from a disposed (or never-connected) client.
373
+ *
374
+ * @returns A promise that resolves with no value once the wallet is past its
375
+ * initial `'pending'`/`'reconnecting'` warm-up. Already resolved when the
376
+ * status is not transient.
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * // On a chain switch, hold the old UI until the rebuilt client is ready:
381
+ * const next = createClient().use(walletSigner({ chain }));
382
+ * await next.wallet.whenReady();
383
+ * setClient(next);
384
+ * ```
385
+ */
386
+ whenReady: () => Promise<void>;
338
387
  };
339
388
 
340
389
  /**
package/src/wallet.ts CHANGED
@@ -22,7 +22,9 @@ function defineSignerGetter(
22
22
  ): void {
23
23
  Object.defineProperty(additions, property, {
24
24
  configurable: true,
25
- enumerable: true,
25
+ // Non-enumerable on purpose: this getter throws when no signer is connected, so we
26
+ // don't want things like Object.entries to read it.
27
+ enumerable: false,
26
28
  get() {
27
29
  const state = store.getState();
28
30
  if (!state.connected) {