Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clear-teams-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@solana/react': minor
---

Add `usePayer` and `useIdentity` React hooks. Each reads the corresponding value off the client and, when the client advertises `subscribeToPayer`/`subscribeToIdentity`, subscribes so the returned signer always reflects the latest payer/identity. Clients whose value is fixed fall back to a one-time read.

If the plugin value throws (for example as the wallet plugin does when it owns payer/identity and a wallet is not connected), this is surfaced as `undefined` in the hooks.
1 change: 1 addition & 0 deletions examples/react-app/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function Root() {
// with the rpc/subscriptions those cells read — rather than a render early.
const { chain } = useClient<AppClient>();
const { connected, isStale } = useDisplayedWallet();

if (!connected) {
return (
<Flex gap="6" direction="column">
Expand Down
26 changes: 26 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ function SendButton({ client, instructions }) {
- **`usePlanTransaction(client)`** / **`usePlanTransactions(client)`** — plan a single, or multiple, transaction message(s) from an instruction input. Requires a client with transaction planning installed (`ClientWithTransactionPlanning`). `usePlanTransaction` resolves with a single transaction message; `usePlanTransactions` resolves with the full transaction plan.
- **`useSendTransaction(client)`** / **`useSendTransactions(client)`** — sign, submit, and confirm a single, or multiple, transaction(s). Requires a client with transaction sending installed (`ClientWithTransactionSending`). Both accept flexible input (instructions, an instruction plan, or a transaction plan); `useSendTransaction` additionally accepts a single transaction message.

### Payer & identity

Read the signer a client uses to pay for transactions (`payer`), or the wallet whose on-chain assets the app acts upon (`identity`), and re-render whenever it changes. Both hooks return the current `TransactionSigner`, or `undefined` while none is available.

```tsx
import { useClient, useIdentity, usePayer } from '@solana/react';
import type { AppClient } from './client';

function AccountBar() {
const client = useClient<AppClient>();
const identity = useIdentity(client);
const payer = usePayer(client);
return (
<div>
<span>{identity ? `Signed in as ${identity.address}` : 'Signed out'}</span>
<span>{payer ? `Paying with ${payer.address}` : 'No payer'}</span>
</div>
);
}
```

- **`usePayer(client)`** — reads `client.payer`. Requires a client with a payer plugin installed (`ClientWithPayer`).
- **`useIdentity(client)`** — reads `client.identity`. Requires a client with an identity plugin installed (`ClientWithIdentity`).

When the client also advertises `subscribeToPayer` / `subscribeToIdentity` (`ClientWithSubscribeToPayer` / `ClientWithSubscribeToIdentity`), each hook subscribes so the returned value always reflects the latest signer. For a client whose payer or identity is fixed for its lifetime, it falls back to a no-op subscription and reads the value once.

### `useRequest(source, options?)`

Fires a one-shot request on mount and re-fires whenever `source` changes identity. Returns `{ data, error, status, refresh }` where `status` is one of `'fetching' | 'success' | 'error' | 'disabled'`. Use it for RPC reads, or for any other one-shot async work an app needs (a `fetch`, a third-party SDK call, etc.).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe('ClientProvider + useClient', () => {
const clientPromise = Promise.reject<Client<object>>(boom);
// Pre-attach a catch so the rejection isn't flagged as unhandled before React's
// error-boundary subscription runs.
clientPromise.catch(() => { });
clientPromise.catch(() => {});
const onError = jest.fn();
function Probe() {
useClient();
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/__tests__/useAction-test.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ describe('useAction', () => {

it('uses the latest fn closure on each new call', async () => {
let captured: number | null = null;
const { result, rerender } = renderHook(({ value }: { value: number }) => useAction(async () => (captured = value)), { initialProps: { value: 1 } });
const { result, rerender } = renderHook(
({ value }: { value: number }) => useAction(async () => (captured = value)),
{ initialProps: { value: 1 } },
);

await act(async () => {
await result.current.dispatchAsync();
Expand Down
96 changes: 96 additions & 0 deletions packages/react/src/__tests__/useIdentity-test.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { ClientWithIdentity, ClientWithSubscribeToIdentity, TransactionSigner } from '@solana/kit';
import { act } from '@testing-library/react';

import { renderHook } from '../__test-utils__/render';
import { useIdentity } from '../useIdentity';

describe('useIdentity', () => {
const signerA = { address: 'A' } as unknown as TransactionSigner;
const signerB = { address: 'B' } as unknown as TransactionSigner;

it('returns the current identity', () => {
const client = { identity: signerA } as ClientWithIdentity;
const { result } = renderHook(() => useIdentity(client));
expect(result.current).toBe(signerA);
});

it('subscribes and re-renders with the latest identity when the client is reactive', () => {
let listener: (() => void) | undefined;
const client = {
identity: signerA,
subscribeToIdentity: jest.fn(l => {
listener = l;
return () => {};
}),
} as ClientWithIdentity & ClientWithSubscribeToIdentity;
const { result } = renderHook(() => useIdentity(client));

expect(result.current).toBe(signerA);
expect(client.subscribeToIdentity).toHaveBeenCalled();

act(() => {
client.identity = signerB;
listener!();
});
expect(result.current).toBe(signerB);
});

it('unsubscribes on unmount', () => {
const unsubscribe = jest.fn();
const client = {
identity: signerA,
subscribeToIdentity: jest.fn(() => unsubscribe),
} as ClientWithIdentity & ClientWithSubscribeToIdentity;
const { unmount } = renderHook(() => useIdentity(client));

unmount();
expect(unsubscribe).toHaveBeenCalled();
});

it('returns undefined while the identity getter throws, then recovers when it becomes available', () => {
let connected = false;
let listener: (() => void) | undefined;
const client = {
get identity() {
if (!connected) {
throw new Error('No signing wallet connected');
}
return signerA;
},
subscribeToIdentity: jest.fn(l => {
listener = l;
return () => {};
}),
} as ClientWithIdentity & ClientWithSubscribeToIdentity;
const { result } = renderHook(() => useIdentity(client));

expect(result.current).toBeUndefined();

act(() => {
connected = true;
listener!();
});
expect(result.current).toBe(signerA);

act(() => {
connected = false;
listener!();
});
expect(result.current).toBeUndefined();
});

it('has no subscription to react to changes when the client is not reactive, but reads the latest value on the next render', () => {
const client = { identity: signerA } as ClientWithIdentity;
const { result, rerender } = renderHook(() => useIdentity(client));
expect(result.current).toBe(signerA);

// Without a `subscribeToIdentity` function there is nothing to trigger a re-render on its
// own, so a bare mutation is not observed...
client.identity = signerB;
expect(result.current).toBe(signerA);

// ...but the current value is read again on the next render.
rerender();
expect(result.current).toBe(signerB);
});
});
96 changes: 96 additions & 0 deletions packages/react/src/__tests__/usePayer-test.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { ClientWithPayer, ClientWithSubscribeToPayer, TransactionSigner } from '@solana/kit';
import { act } from '@testing-library/react';

import { renderHook } from '../__test-utils__/render';
import { usePayer } from '../usePayer';

describe('usePayer', () => {
const signerA = { address: 'A' } as unknown as TransactionSigner;
const signerB = { address: 'B' } as unknown as TransactionSigner;

it('returns the current payer', () => {
const client = { payer: signerA } as ClientWithPayer;
const { result } = renderHook(() => usePayer(client));
expect(result.current).toBe(signerA);
});

it('subscribes and re-renders with the latest payer when the client is reactive', () => {
let listener: (() => void) | undefined;
const client = {
payer: signerA,
subscribeToPayer: jest.fn(l => {
listener = l;
return () => {};
}),
} as ClientWithPayer & ClientWithSubscribeToPayer;
const { result } = renderHook(() => usePayer(client));

expect(result.current).toBe(signerA);
expect(client.subscribeToPayer).toHaveBeenCalled();

act(() => {
client.payer = signerB;
listener!();
});
expect(result.current).toBe(signerB);
});

it('unsubscribes on unmount', () => {
const unsubscribe = jest.fn();
const client = {
payer: signerA,
subscribeToPayer: jest.fn(() => unsubscribe),
} as ClientWithPayer & ClientWithSubscribeToPayer;
const { unmount } = renderHook(() => usePayer(client));

unmount();
expect(unsubscribe).toHaveBeenCalled();
});

it('returns undefined while the payer getter throws, then recovers when it becomes available', () => {
let connected = false;
let listener: (() => void) | undefined;
const client = {
get payer() {
if (!connected) {
throw new Error('No signing wallet connected');
}
return signerA;
},
subscribeToPayer: jest.fn(l => {
listener = l;
return () => {};
}),
} as ClientWithPayer & ClientWithSubscribeToPayer;
const { result } = renderHook(() => usePayer(client));

expect(result.current).toBeUndefined();

act(() => {
connected = true;
listener!();
});
expect(result.current).toBe(signerA);

act(() => {
connected = false;
listener!();
});
expect(result.current).toBeUndefined();
});

it('has no subscription to react to changes when the client is not reactive, but reads the latest value on the next render', () => {
const client = { payer: signerA } as ClientWithPayer;
const { result, rerender } = renderHook(() => usePayer(client));
expect(result.current).toBe(signerA);

// Without a `subscribeToPayer` function there is nothing to trigger a re-render on its
// own, so a bare mutation is not observed...
client.payer = signerB;
expect(result.current).toBe(signerA);

// ...but the current value is read again on the next render.
rerender();
expect(result.current).toBe(signerB);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,7 @@ describe('useReactiveStoreLifecycle', () => {
for (let i = 0; i < 40; i++) {
rerender({ store: makeFakeStore() });
}
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('recreated its store'),
expect.any(Number),
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('recreated its store'), expect.any(Number));
});

it('does not warn when the store identity is stable across renders', () => {
Expand All @@ -124,10 +121,7 @@ describe('useReactiveStoreLifecycle', () => {
for (let i = 0; i < 40; i++) {
rerender({ store: makeFakeStore() });
}
expect(errorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('recreated its store'),
expect.anything(),
);
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('recreated its store'), expect.anything());
errorSpy.mockRestore();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('useSubscription', () => {
});

it('passes raw notifications through unchanged', async () => {
const sub = makeFakeSubscription<{ parent: bigint, root: bigint, slot: bigint }>();
const sub = makeFakeSubscription<{ parent: bigint; root: bigint; slot: bigint }>();
const { result } = renderHook(() => useSubscription(sub.source));

await act(async () => await sub.publish({ parent: 9n, root: 8n, slot: 10n }));
Expand Down
27 changes: 27 additions & 0 deletions packages/react/src/__typetests__/useIdentity-typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable react-hooks/rules-of-hooks */

import type { ClientWithIdentity, ClientWithSubscribeToIdentity, TransactionSigner } from '@solana/kit';

import { useIdentity } from '../useIdentity';

// [DESCRIBE] useIdentity
{
// It returns the identity signer (or undefined while absent) for a static client
{
const client = {} as ClientWithIdentity;
useIdentity(client) satisfies TransactionSigner | undefined;
}

// It accepts a reactive client that also advertises subscribeToIdentity
{
const client = {} as ClientWithIdentity & ClientWithSubscribeToIdentity;
useIdentity(client) satisfies TransactionSigner | undefined;
}

// It rejects a client that lacks an identity
{
const client = {} as ClientWithSubscribeToIdentity;
// @ts-expect-error - client must have an identity
useIdentity(client);
}
}
27 changes: 27 additions & 0 deletions packages/react/src/__typetests__/usePayer-typetest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable react-hooks/rules-of-hooks */

import type { ClientWithPayer, ClientWithSubscribeToPayer, TransactionSigner } from '@solana/kit';

import { usePayer } from '../usePayer';

// [DESCRIBE] usePayer
{
// It returns the payer signer (or undefined while absent) for a static client
{
const client = {} as ClientWithPayer;
usePayer(client) satisfies TransactionSigner | undefined;
}

// It accepts a reactive client that also advertises subscribeToPayer
{
const client = {} as ClientWithPayer & ClientWithSubscribeToPayer;
usePayer(client) satisfies TransactionSigner | undefined;
}

// It rejects a client that lacks a payer
{
const client = {} as ClientWithSubscribeToPayer;
// @ts-expect-error - client must have a payer
usePayer(client);
}
}
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export * from './ClientProvider';
export * from './useAction';
export * from './useClient';
export * from './useClientCapability';
export * from './useIdentity';
export * from './usePayer';
export * from './usePlanTransaction';
export * from './usePlanTransactions';
export * from './useRequest';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ function createWrapper() {
type DeepPartial<T> = T extends (...args: infer A) => infer R
? (...args: A) => DeepPartial<R>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

// Wraps a partial `ReactiveActionStore` stub into a `ReactiveActionSource`, isolating the
// type-narrowing cast to one place so spy-on-store tests stay legible.
Expand Down
10 changes: 4 additions & 6 deletions packages/react/src/swr/__tests__/useRequestSWR-test.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,9 @@ describe('useRequestSWR', () => {
const withSignal = jest.fn(() => ({ dispatchAsync }));
const source = stubActionSource<string>({ dispatchAsync, withSignal });
const ctrl = new AbortController();
renderHook(
() => useRequestSWR(['source-signal-identity'], source, { getAbortSignal: () => ctrl.signal }),
{ wrapper },
);
renderHook(() => useRequestSWR(['source-signal-identity'], source, { getAbortSignal: () => ctrl.signal }), {
wrapper,
});
await waitFor(() => expect(withSignal).toHaveBeenCalledWith(ctrl.signal));
});

Expand Down Expand Up @@ -321,8 +320,7 @@ describe('useRequestSWR', () => {
reactiveStore: () => createReactiveActionStore<[], string>(() => Promise.resolve(value)),
});
const { result, rerender } = renderHook(
({ source }: { source: ReactiveActionSource<string> }) =>
useRequestSWR(['source-latest'], source),
({ source }: { source: ReactiveActionSource<string> }) => useRequestSWR(['source-latest'], source),
{ initialProps: { source: sourceFor('a') }, wrapper },
);
await waitFor(() => expect(result.current.data).toBe('a'));
Expand Down
Loading
Loading