@solana/kit-plugin-wallet 0.12.0 → 0.13.0

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 +117 -36
  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 +5 -2
  9. package/dist/index.node.mjs.map +1 -1
  10. package/dist/index.react-native.mjs +5 -2
  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 +59 -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 +59 -0
  19. package/dist/react/index.node.mjs.map +1 -0
  20. package/dist/react/index.react-native.mjs +59 -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 +43 -13
  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/README.md CHANGED
@@ -149,9 +149,9 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref
149
149
  }
150
150
  ```
151
151
 
152
- - **`disconnect()`** — Disconnect the active wallet.
152
+ - **`disconnect(wallet?)`** — Disconnects an authorized wallet. If `wallet` is not provided, disconnects the active wallet.
153
153
 
154
- - **`selectAccount(account)`** — Switch to a different account within an already-authorized wallet without reconnecting.
154
+ - **`selectAccount(account)`** — Switch to a different account within any already-authorized wallet without reconnecting.
155
155
 
156
156
  ```ts
157
157
  client.wallet.selectAccount(accounts[0]);
@@ -169,11 +169,109 @@ All wallet state is accessed via `client.wallet.getState()`, which returns a ref
169
169
  const output = await client.wallet.signIn(selectedWallet, { domain: window.location.host });
170
170
  ```
171
171
 
172
+ ## React hooks
173
+
174
+ The `@solana/kit-plugin-wallet/react` subpath exposes hooks for reading wallet state and driving wallet actions from React components. Install the optional peer dependencies (`react` and [`@solana/react`](https://www.npmjs.com/package/@solana/react)) and render your tree inside a `ClientProvider` whose client has a wallet plugin installed.
175
+
176
+ The **state** hooks subscribe via `useSyncExternalStore`, each to a single slice of `WalletState`, so a component only re-renders when the slice it reads changes:
177
+
178
+ | Hook | Returns |
179
+ | -------------------- | -------------------------------------------------------------------------------- |
180
+ | `useWalletStatus` | The current `WalletStatus`. |
181
+ | `useConnectedWallet` | The active connection (`{ account, signer, wallet }`) or null. |
182
+ | `useWallets` | The discovered wallets for the client's chain. |
183
+ | `useIsWalletReady` | `false` while the initial warm-up is in progress (`'pending'`/`'reconnecting'`). |
184
+
185
+ The **action** hooks wrap the async wallet actions with `useAction` from `@solana/react`, returning its result (`dispatch`, `dispatchAsync`, `data`, `error`, `status`, `isRunning`, `reset`, …). The hook manages the `AbortSignal` internally, so an in-flight call is aborted when a newer one is dispatched:
186
+
187
+ | Hook | Wraps |
188
+ | ------------------ | ------------------------------------------------------------------------- |
189
+ | `useConnect` | `client.wallet.connect` — `dispatch(wallet)` |
190
+ | `useDisconnect` | `client.wallet.disconnect` — `dispatch()` |
191
+ | `useSignIn` | `client.wallet.signIn` — `dispatch(wallet, input)` |
192
+ | `useSignMessage` | `client.wallet.signMessage` — `dispatch(message)` |
193
+ | `useSelectAccount` | `client.wallet.selectAccount` — returns the synchronous callback directly |
194
+
195
+ ```tsx
196
+ import { useConnect, useConnectedWallet, useWallets, useWalletStatus } from '@solana/kit-plugin-wallet/react';
197
+
198
+ function WalletButton() {
199
+ const status = useWalletStatus();
200
+ const wallets = useWallets();
201
+ const connected = useConnectedWallet();
202
+ const { dispatch: connect, isRunning } = useConnect();
203
+
204
+ if (status === 'pending') return null; // avoid flashing UI before auto-reconnect resolves
205
+
206
+ if (connected) return <p>Connected: {connected.account.address}</p>;
207
+
208
+ return wallets.map(wallet => (
209
+ <button key={wallet.name} disabled={isRunning} onClick={() => connect(wallet)}>
210
+ Connect {wallet.name}
211
+ </button>
212
+ ));
213
+ }
214
+ ```
215
+
216
+ ### Gating UI on the initial warm-up
217
+
218
+ A freshly built client runs a silent auto-reconnect on mount, briefly passing through `'pending'` and `'reconnecting'` before it settles. If you render wallet-dependent UI immediately, a persisted wallet flashes "disconnected" for a frame before it reconnects. `useIsWalletReady` reports `false` for the duration of that warm-up and `true` once it settles, so you can hold back the wallet-dependent parts while painting the rest of the app:
219
+
220
+ ```tsx
221
+ import { useIsWalletReady, WalletReadyGate } from '@solana/kit-plugin-wallet/react';
222
+
223
+ // Hide a whole subtree until the connection settles:
224
+ function App() {
225
+ return (
226
+ <WalletReadyGate fallback={<Spinner />}>
227
+ <WalletDependentUI />
228
+ </WalletReadyGate>
229
+ );
230
+ }
231
+
232
+ // Or read the boolean directly for finer control (e.g. disabling a button):
233
+ function ConnectButton() {
234
+ const isReady = useIsWalletReady();
235
+ return <button disabled={!isReady}>Connect</button>;
236
+ }
237
+ ```
238
+
239
+ `WalletReadyGate` is a thin wrapper over `useIsWalletReady`. Its props are shaped like React's `<Suspense fallback>`, but it is synchronous — it renders `fallback` directly off the reactive status rather than suspending, so it needs no `<Suspense>` ancestor. Both gate on the same warm-up set as `whenReady()` below, so they stay in sync — and like `whenReady()`, they ignore user-initiated `'connecting'` / `'disconnecting'`. To genuinely suspend a subtree, throw the Suspense-safe `whenReady()` promise from your own component instead.
240
+
241
+ ### Advanced: seamless client swaps with `whenReady()`
242
+
243
+ > Most apps don't need this. It only matters if you **rebuild the client at runtime** — for example, to change chain — since each wallet plugin is bound to a single chain.
244
+
245
+ Every freshly built client runs a silent auto-reconnect that briefly passes through the `'pending'` and `'reconnecting'` statuses. If you publish the new client as soon as you build it, the UI may flash that reconnecting state on every switch. `client.wallet.whenReady()` returns a promise that resolves once the status has left `'pending'` / `'reconnecting'`, so you can build the new client, wait for it to settle, and only then swap it in — keeping the previous client live until the new one is ready:
246
+
247
+ ```ts
248
+ import { createClient } from '@solana/kit';
249
+ import { walletSigner } from '@solana/kit-plugin-wallet';
250
+
251
+ // On a chain switch, hold the previous client until the rebuilt one is ready:
252
+ const next = createClient().use(walletSigner({ chain }));
253
+ await next.wallet.whenReady();
254
+ swapInClient(next); // publish `next`, then dispose the client it replaced
255
+ ```
256
+
257
+ `whenReady()` deliberately does **not** wait for user-initiated `connect` / `disconnect` / `signIn` (those pass through `'connecting'` / `'disconnecting'`), so it only ever blocks on the initial warm-up — a normal connect stays interactive.
258
+
259
+ Disposing a client mid-warm-up settles its status to `'disconnected'` and resolves any pending `whenReady()` promise — a superseded client never leaves an `await` hanging. Check `getState()` after awaiting if you need to tell a ready connection from a disposed (or never-connected) client.
260
+
172
261
  ## Framework integration
173
262
 
174
- The plugin exposes `subscribe` and `getState` for binding wallet state to any UI framework.
263
+ The plugin exposes `subscribe` and `getState` for binding wallet state to any UI framework. To gate UI on the initial auto-reconnect warm-up without React, pass `getState().status` through `isWalletWarmingUp` — the same predicate that backs `whenReady()` and the [React readiness helpers](#gating-ui-on-the-initial-warm-up), so all three stay in sync:
264
+
265
+ ```ts
266
+ import { isWalletWarmingUp } from '@solana/kit-plugin-wallet';
267
+
268
+ client.wallet.subscribe(() => {
269
+ const { status } = client.wallet.getState();
270
+ setLoading(isWalletWarmingUp(status)); // true while 'pending' / 'reconnecting'
271
+ });
272
+ ```
175
273
 
176
- **React** — use `useSyncExternalStore` for concurrent-mode-safe rendering:
274
+ **React** — prefer the ready-made [React hooks](#react-hooks) above; they wrap the pattern below. To bind state manually (e.g. to read the whole snapshot at once), use `useSyncExternalStore` for concurrent-mode-safe rendering:
177
275
 
178
276
  ```tsx
179
277
  import { useSyncExternalStore } from 'react';
@@ -8,7 +8,10 @@ var features = require('@wallet-standard/features');
8
8
  var uiFeatures = require('@wallet-standard/ui-features');
9
9
  var uiRegistry = require('@wallet-standard/ui-registry');
10
10
 
11
- // src/wallet.ts
11
+ // src/status.ts
12
+ function isWalletWarmingUp(status) {
13
+ return status === "pending" || status === "reconnecting";
14
+ }
12
15
  function createWalletStore(config) {
13
16
  let state = {
14
17
  account: null,
@@ -19,12 +22,13 @@ function createWalletStore(config) {
19
22
  };
20
23
  const listeners = /* @__PURE__ */ new Set();
21
24
  const signerListeners = /* @__PURE__ */ new Set();
22
- let walletEventsCleanup = null;
25
+ const walletEventSubscriptions = /* @__PURE__ */ new Map();
23
26
  let reconnectCleanup = null;
24
27
  let userHasSelected = false;
25
28
  let connectGeneration = 0;
26
29
  let disposed = false;
27
30
  let disconnectingWalletName = null;
31
+ let readyDeferred = null;
28
32
  function resolveDefaultStorage() {
29
33
  try {
30
34
  return localStorage;
@@ -52,6 +56,13 @@ function createWalletStore(config) {
52
56
  function updateState(updates) {
53
57
  const prev = state;
54
58
  state = { ...state, ...updates };
59
+ if (isWalletWarmingUp(prev.status) && !isWalletWarmingUp(state.status)) {
60
+ readyDeferred?.resolve();
61
+ readyDeferred = null;
62
+ }
63
+ if (state.wallets !== prev.wallets) {
64
+ syncWalletEventSubscriptions(state.wallets);
65
+ }
55
66
  const signerChanged = observableSigner(state) !== observableSigner(prev);
56
67
  if (state.connectedWallet !== prev.connectedWallet || state.account !== prev.account || state.status !== prev.status || state.signer !== prev.signer || state.wallets !== prev.wallets) {
57
68
  snapshot = deriveSnapshot(state);
@@ -61,6 +72,13 @@ function createWalletStore(config) {
61
72
  signerListeners.forEach((l) => l());
62
73
  }
63
74
  }
75
+ function whenReady() {
76
+ if (!isWalletWarmingUp(state.status)) {
77
+ return Promise.resolve();
78
+ }
79
+ readyDeferred ??= Promise.withResolvers();
80
+ return readyDeferred.promise;
81
+ }
64
82
  function tryCreateSigner(account) {
65
83
  try {
66
84
  return walletAccountSigner.createSignerFromWalletAccount(account, config.chain);
@@ -86,6 +104,20 @@ function createWalletStore(config) {
86
104
  }
87
105
  return next;
88
106
  }
107
+ function syncWalletEventSubscriptions(wallets) {
108
+ const names = new Set(wallets.map((w) => w.name));
109
+ for (const [name, cleanup] of walletEventSubscriptions) {
110
+ if (!names.has(name)) {
111
+ cleanup();
112
+ walletEventSubscriptions.delete(name);
113
+ }
114
+ }
115
+ for (const w of wallets) {
116
+ if (!walletEventSubscriptions.has(w.name)) {
117
+ walletEventSubscriptions.set(w.name, subscribeToWalletEvents(w));
118
+ }
119
+ }
120
+ }
89
121
  function isWalletStillAvailable(uiWallet) {
90
122
  return buildWalletList().some((w) => w.name === uiWallet.name);
91
123
  }
@@ -97,8 +129,6 @@ function createWalletStore(config) {
97
129
  const newWallets = reconcileWalletList();
98
130
  const updates = { wallets: newWallets };
99
131
  if (state.connectedWallet && !newWallets.some((w) => w.name === state.connectedWallet.name)) {
100
- walletEventsCleanup?.();
101
- walletEventsCleanup = null;
102
132
  updates.connectedWallet = null;
103
133
  updates.account = null;
104
134
  updates.signer = null;
@@ -111,16 +141,17 @@ function createWalletStore(config) {
111
141
  reconnectCleanup?.();
112
142
  reconnectCleanup = null;
113
143
  }
114
- function resubscribeToWalletEvents(uiWallet) {
115
- walletEventsCleanup?.();
116
- walletEventsCleanup = subscribeToWalletEvents(uiWallet);
117
- }
118
144
  function setConnected(account, wallet, options) {
119
145
  if (disposed) return;
120
146
  const signer = tryCreateSigner(account);
121
- resubscribeToWalletEvents(wallet);
122
147
  disconnectingWalletName = null;
123
- updateState({ account, connectedWallet: wallet, signer, status: "connected" });
148
+ updateState({
149
+ account,
150
+ connectedWallet: wallet,
151
+ signer,
152
+ status: "connected",
153
+ wallets: reconcileWalletList()
154
+ });
124
155
  if (options?.persist !== false) {
125
156
  persistAccount(account, wallet);
126
157
  }
@@ -138,8 +169,14 @@ function createWalletStore(config) {
138
169
  uiWallet,
139
170
  features.StandardEvents
140
171
  );
172
+ const walletName = uiWallet.name;
141
173
  return eventsFeature.on("change", () => {
142
- const refreshed = refreshUiWallet(uiWallet);
174
+ const newWallets = reconcileWalletList();
175
+ if (!state.connectedWallet || state.connectedWallet.name !== walletName) {
176
+ updateState({ wallets: newWallets });
177
+ return;
178
+ }
179
+ const refreshed = refreshUiWallet(state.connectedWallet);
143
180
  if (!filterWallet(refreshed)) {
144
181
  disconnectLocally();
145
182
  return;
@@ -149,7 +186,7 @@ function createWalletStore(config) {
149
186
  disconnectLocally();
150
187
  return;
151
188
  }
152
- updateState({ connectedWallet: refreshed, wallets: reconcileWalletList(), ...result });
189
+ updateState({ connectedWallet: refreshed, wallets: newWallets, ...result });
153
190
  if (result.account) {
154
191
  persistAccount(result.account, refreshed);
155
192
  }
@@ -199,9 +236,10 @@ function createWalletStore(config) {
199
236
  throw error;
200
237
  }
201
238
  }
202
- async function disconnect(options) {
239
+ async function disconnect(wallet, options) {
203
240
  options?.abortSignal?.throwIfAborted();
204
- if (!state.connectedWallet) {
241
+ const target = wallet ?? state.connectedWallet;
242
+ if (!target) {
205
243
  userHasSelected = true;
206
244
  cancelReconnect();
207
245
  connectGeneration++;
@@ -209,27 +247,52 @@ function createWalletStore(config) {
209
247
  clearPersistedAccount();
210
248
  return;
211
249
  }
212
- const currentWallet = state.connectedWallet;
213
- const generation = ++connectGeneration;
214
- disconnectingWalletName = currentWallet.name;
215
- updateState({ status: "disconnecting" });
216
- try {
217
- if (currentWallet.features.includes(features.StandardDisconnect)) {
218
- const disconnectFeature = uiFeatures.getWalletFeature(
219
- currentWallet,
220
- features.StandardDisconnect
221
- );
222
- await disconnectFeature.disconnect();
250
+ const isActive = state.connectedWallet != null && state.connectedWallet.name === target.name;
251
+ if (isActive) {
252
+ const currentWallet = state.connectedWallet;
253
+ const generation = ++connectGeneration;
254
+ disconnectingWalletName = currentWallet.name;
255
+ updateState({ status: "disconnecting" });
256
+ try {
257
+ if (currentWallet.features.includes(features.StandardDisconnect)) {
258
+ const disconnectFeature = uiFeatures.getWalletFeature(
259
+ currentWallet,
260
+ features.StandardDisconnect
261
+ );
262
+ await disconnectFeature.disconnect();
263
+ }
264
+ } finally {
265
+ if (generation === connectGeneration) {
266
+ disconnectLocally();
267
+ }
223
268
  }
269
+ return;
270
+ }
271
+ if (!isWalletStillAvailable(target)) {
272
+ return;
273
+ }
274
+ const refreshedTarget = refreshUiWallet(target);
275
+ if (!refreshedTarget.features.includes(features.StandardDisconnect)) {
276
+ return;
277
+ }
278
+ const prevDisconnecting = disconnectingWalletName;
279
+ disconnectingWalletName = refreshedTarget.name;
280
+ try {
281
+ const disconnectFeature = uiFeatures.getWalletFeature(
282
+ refreshedTarget,
283
+ features.StandardDisconnect
284
+ );
285
+ await disconnectFeature.disconnect();
224
286
  } finally {
225
- if (generation === connectGeneration) {
226
- disconnectLocally();
287
+ if (disconnectingWalletName === refreshedTarget.name) {
288
+ disconnectingWalletName = prevDisconnecting;
289
+ }
290
+ if (!disposed) {
291
+ updateState({ wallets: reconcileWalletList() });
227
292
  }
228
293
  }
229
294
  }
230
295
  function disconnectLocally() {
231
- walletEventsCleanup?.();
232
- walletEventsCleanup = null;
233
296
  disconnectingWalletName = null;
234
297
  updateState({
235
298
  account: null,
@@ -255,13 +318,25 @@ function createWalletStore(config) {
255
318
  }
256
319
  }
257
320
  function selectAccount(account) {
258
- if (!state.connectedWallet) {
259
- throw new kit.SolanaError(kit.SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: "selectAccount" });
321
+ let owner;
322
+ try {
323
+ owner = uiRegistry.getOrCreateUiWalletForStandardWallet(uiRegistry.getWalletForHandle(account));
324
+ } catch {
325
+ throw new kit.SolanaError(kit.SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
326
+ account: account.address,
327
+ operation: "selectAccount"
328
+ });
260
329
  }
261
- if (state.connectedWallet.name === disconnectingWalletName) {
330
+ if (!isWalletStillAvailable(owner)) {
331
+ throw new kit.SolanaError(kit.SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
332
+ account: account.address,
333
+ operation: "selectAccount"
334
+ });
335
+ }
336
+ if (owner.name === disconnectingWalletName) {
262
337
  throw new kit.SolanaError(kit.SOLANA_ERROR__WALLET__NOT_CONNECTED, { operation: "selectAccount" });
263
338
  }
264
- const refreshed = refreshUiWallet(state.connectedWallet);
339
+ const refreshed = refreshUiWallet(owner);
265
340
  const selectedAccount = refreshed.accounts.find((a) => a.address === account.address);
266
341
  if (!selectedAccount) {
267
342
  throw new kit.SolanaError(kit.SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, {
@@ -466,16 +541,22 @@ function createWalletStore(config) {
466
541
  signerListeners.delete(listener);
467
542
  };
468
543
  },
544
+ whenReady,
469
545
  [Symbol.dispose]: () => {
470
546
  disposed = true;
471
547
  connectGeneration++;
472
548
  unsubRegister();
473
549
  unsubUnregister();
474
- walletEventsCleanup?.();
475
- walletEventsCleanup = null;
550
+ for (const cleanup of walletEventSubscriptions.values()) {
551
+ cleanup();
552
+ }
553
+ walletEventSubscriptions.clear();
476
554
  cancelReconnect();
477
555
  listeners.clear();
478
556
  signerListeners.clear();
557
+ if (isWalletWarmingUp(state.status)) {
558
+ updateState({ status: "disconnected" });
559
+ }
479
560
  }
480
561
  };
481
562
  }
@@ -484,7 +565,9 @@ function createWalletStore(config) {
484
565
  function defineSignerGetter(additions, property, store) {
485
566
  Object.defineProperty(additions, property, {
486
567
  configurable: true,
487
- enumerable: true,
568
+ // Non-enumerable on purpose: this getter throws when no signer is connected, so we
569
+ // don't want things like Object.entries to read it.
570
+ enumerable: false,
488
571
  get() {
489
572
  const state = store.getState();
490
573
  if (!state.connected) {
@@ -530,6 +613,7 @@ function walletWithoutSigner(config) {
530
613
  return createPlugin(config, []);
531
614
  }
532
615
 
616
+ exports.isWalletWarmingUp = isWalletWarmingUp;
533
617
  exports.walletIdentity = walletIdentity;
534
618
  exports.walletPayer = walletPayer;
535
619
  exports.walletSigner = walletSigner;