-
Notifications
You must be signed in to change notification settings - Fork 7
Add SubscribeToPayer/Identity, and use for wallet plugin #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: wallet-plugin
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@solana/kit-plugin-signer': minor | ||
| '@solana/kit-plugin-wallet': minor | ||
| --- | ||
|
|
||
| Add `ClientWithSubscribeToPayer` and `ClientWithSubscribeToIdentity` to `@solana/kit-plugin-signer`. These are a framework-agnostic convention for plugins that mutate `client.payer` / `client.identity` reactively — they install a sibling `subscribeToPayer` / `subscribeToIdentity` function so consumers can observe changes without naming the specific plugin. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { ClientWithSubscribeToIdentity, ClientWithSubscribeToPayer, SubscribeToCapability } from '../subscribe-to'; | ||
|
|
||
| // [DESCRIBE] SubscribeToCapability | ||
| { | ||
| // It takes a listener and returns an unsubscribe function. | ||
| { | ||
| const subscribe = null as unknown as SubscribeToCapability; | ||
| const unsubscribe = subscribe(() => {}); | ||
| unsubscribe satisfies () => void; | ||
| } | ||
| } | ||
|
|
||
| // [DESCRIBE] ClientWithSubscribeToPayer | ||
| { | ||
| // It exposes a readonly `subscribeToPayer` of type `SubscribeToCapability`. | ||
| { | ||
| const client = null as unknown as ClientWithSubscribeToPayer; | ||
| client.subscribeToPayer satisfies SubscribeToCapability; | ||
| } | ||
| } | ||
|
|
||
| // [DESCRIBE] ClientWithSubscribeToIdentity | ||
| { | ||
| // It exposes a readonly `subscribeToIdentity` of type `SubscribeToCapability`. | ||
| { | ||
| const client = null as unknown as ClientWithSubscribeToIdentity; | ||
| client.subscribeToIdentity satisfies SubscribeToCapability; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| /** | ||
| * Convention for advertising that a client capability is reactive. | ||
| * | ||
| * A plugin whose capability can change over time (for example, a wallet | ||
| * plugin whose `client.payer` is reassigned as the user connects, switches | ||
| * accounts, or disconnects) installs a sibling | ||
| * `subscribeTo<Capability>(listener): () => void` function alongside the | ||
| * capability itself. Reactive consumers (framework hooks, stores, effects) | ||
| * can then observe changes without having to name the specific plugin that | ||
| * installed the capability — they duck-type on the subscribe hook. | ||
| * | ||
| * The listener is invoked whenever the observable value of the capability | ||
| * may have changed; consumers should re-read the capability to get the | ||
| * current value. Over-notification is acceptable — consumers that bail on | ||
| * reference-equal snapshots (such as React's `useSyncExternalStore`) will | ||
| * filter redundant notifications out for free. | ||
| * | ||
| * Plugins whose capability is fixed for the lifetime of the client (e.g. a | ||
| * static `payer(signer)` plugin) do not need to install this function. | ||
| * Consumers that care about reactivity should fall back to a no-op subscribe | ||
| * and read the capability once. | ||
| * | ||
| * This module defines the convention for `client.payer` and `client.identity`. | ||
| * See {@link ClientWithSubscribeToPayer} and {@link ClientWithSubscribeToIdentity}. | ||
| */ | ||
|
|
||
| /** | ||
| * Registers a listener for changes to a reactive client capability. Returns | ||
| * an unsubscribe function. | ||
| * | ||
| * Calling the returned unsubscribe more than once is safe — it must be | ||
| * idempotent. | ||
| */ | ||
| export type SubscribeToCapability = (listener: () => void) => () => void; | ||
|
|
||
| /** | ||
| * Advertises that `client.payer` is reactive. | ||
| * | ||
| * A plugin that can mutate `client.payer` over time installs this sibling | ||
| * function so that reactive consumers can re-read the capability without | ||
| * having to know which plugin installed it. | ||
| * | ||
| * The listener is invoked whenever the observable value of `client.payer` | ||
| * may have changed; consumers should re-read `client.payer` to get the | ||
| * current value. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { type ClientWithPayer } from '@solana/kit'; | ||
| * import { type ClientWithSubscribeToPayer } from '@solana/kit-plugin-signer'; | ||
| * | ||
| * function observePayer(client: ClientWithPayer & ClientWithSubscribeToPayer) { | ||
| * return client.subscribeToPayer(() => { | ||
| * console.log('payer is now', client.payer); | ||
| * }); | ||
| * } | ||
| * ``` | ||
| * | ||
| * @see {@link ClientWithSubscribeToIdentity} | ||
| */ | ||
| export type ClientWithSubscribeToPayer = { | ||
| /** | ||
| * Registers a listener to be called whenever `client.payer` may have | ||
| * changed. Returns an unsubscribe function. | ||
| */ | ||
| readonly subscribeToPayer: SubscribeToCapability; | ||
| }; | ||
|
|
||
| /** | ||
| * Advertises that `client.identity` is reactive. | ||
| * | ||
| * A plugin that can mutate `client.identity` over time installs this sibling | ||
| * function so that reactive consumers can re-read the capability without | ||
| * having to know which plugin installed it. | ||
| * | ||
| * The listener is invoked whenever the observable value of `client.identity` | ||
| * may have changed; consumers should re-read `client.identity` to get the | ||
| * current value. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { type ClientWithIdentity } from '@solana/kit'; | ||
| * import { type ClientWithSubscribeToIdentity } from '@solana/kit-plugin-signer'; | ||
| * | ||
| * function observeIdentity(client: ClientWithIdentity & ClientWithSubscribeToIdentity) { | ||
| * return client.subscribeToIdentity(() => { | ||
| * console.log('identity is now', client.identity); | ||
| * }); | ||
| * } | ||
| * ``` | ||
| * | ||
| * @see {@link ClientWithSubscribeToPayer} | ||
| */ | ||
| export type ClientWithSubscribeToIdentity = { | ||
| /** | ||
| * Registers a listener to be called whenever `client.identity` may have | ||
| * changed. Returns an unsubscribe function. | ||
| */ | ||
| readonly subscribeToIdentity: SubscribeToCapability; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { type ClientWithIdentity, type ClientWithPayer, extendClient, withCleanup } from '@solana/kit'; | ||
| import type { ClientWithSubscribeToIdentity, ClientWithSubscribeToPayer } from '@solana/kit-plugin-signer'; | ||
|
|
||
| import { createWalletStore } from './store'; | ||
| import type { ClientWithWallet, WalletPluginConfig } from './types'; | ||
|
|
@@ -42,6 +43,26 @@ function createPlugin<TAdditions extends ClientWithWallet>(config: WalletPluginC | |
| const additions: Record<string, unknown> = { wallet: store }; | ||
| for (const prop of signerProperties) { | ||
| defineSignerGetter(additions, prop, store); | ||
| // Install the matching `subscribeTo<Capability>` hook so reactive | ||
| // consumers can observe changes without naming this plugin | ||
| // directly. Filter at the source on signer identity so listeners | ||
| // only fire when `client.payer` / `client.identity` would actually | ||
| // resolve to a different value — unrelated wallet state changes | ||
| // (e.g. discovery) are silently absorbed. | ||
| const subscribeProp = | ||
| prop === 'payer' ? 'subscribeToPayer' : prop === 'identity' ? 'subscribeToIdentity' : null; | ||
| if (subscribeProp) { | ||
| additions[subscribeProp] = (listener: () => void) => { | ||
| let last = store.getState().connected?.signer ?? null; | ||
| return store.subscribe(() => { | ||
| const curr = store.getState().connected?.signer ?? null; | ||
| if (curr !== last) { | ||
| last = curr; | ||
| listener(); | ||
| } | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small observation, no change requested: the subscribe factory is allocated per-property inside the loop. With only two capabilities that's fine, but if the |
||
| }; | ||
| } | ||
| } | ||
|
|
||
| return withCleanup(extendClient(client, additions), () => store[Symbol.dispose]()) as unknown as Disposable & | ||
|
|
@@ -79,7 +100,13 @@ function createPlugin<TAdditions extends ClientWithWallet>(config: WalletPluginC | |
| * @see {@link WalletPluginConfig} | ||
| */ | ||
| export function walletSigner(config: WalletPluginConfig) { | ||
| return createPlugin<ClientWithIdentity & ClientWithPayer & ClientWithWallet>(config, ['payer', 'identity']); | ||
| return createPlugin< | ||
| ClientWithIdentity & | ||
| ClientWithPayer & | ||
| ClientWithSubscribeToIdentity & | ||
| ClientWithSubscribeToPayer & | ||
| ClientWithWallet | ||
| >(config, ['payer', 'identity']); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -109,7 +136,7 @@ export function walletSigner(config: WalletPluginConfig) { | |
| * @see {@link WalletPluginConfig} | ||
| */ | ||
| export function walletIdentity(config: WalletPluginConfig) { | ||
| return createPlugin<ClientWithIdentity & ClientWithWallet>(config, ['identity']); | ||
| return createPlugin<ClientWithIdentity & ClientWithSubscribeToIdentity & ClientWithWallet>(config, ['identity']); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -139,7 +166,7 @@ export function walletIdentity(config: WalletPluginConfig) { | |
| * @see {@link WalletPluginConfig} | ||
| */ | ||
| export function walletPayer(config: WalletPluginConfig) { | ||
| return createPlugin<ClientWithPayer & ClientWithWallet>(config, ['payer']); | ||
| return createPlugin<ClientWithPayer & ClientWithSubscribeToPayer & ClientWithWallet>(config, ['payer']); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docblock guarantees idempotent unsubscribe ("Calling the returned unsubscribe more than once is safe"). The current wallet-plugin implementation delegates to
store.subscribe, whose unsubscribe is alisteners.delete(listener)— naturally idempotent. Worth noting that any future plugin adopting this convention needs to uphold that contract; consider whether you want a type-level or test-level marker to make that obvious. Not blocking.