Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3473f6d
Add `ClientProvider`, `useClient`, and `useClientCapability`
mcintyre94 May 7, 2026
86cdd33
Add `useAction` for tracked async actions
mcintyre94 May 8, 2026
1a67532
Drop auto-fire from `PendingRpcRequest.reactiveStore()`
mcintyre94 May 13, 2026
5efa78e
Add `withSignal()` to `ReactiveActionStore` for per-dispatch cancella…
mcintyre94 May 12, 2026
a3fced9
Preserve last `error` through `running` for stale-while-revalidate
mcintyre94 May 12, 2026
7e1df92
Add `useRequest` for one-shot RPC reads
mcintyre94 May 14, 2026
9cad348
Drop auto-connect from stream stores; callers invoke `connect()` expl…
mcintyre94 May 18, 2026
1b2b3f3
Add `withSignal()` to `ReactiveStreamStore` for per-connection cancel…
mcintyre94 May 18, 2026
74b30a6
Collapse `loading` and `retrying` into a single `loading` status
mcintyre94 May 19, 2026
2606839
Add `UnwrapRpcResponse<T>` + `isSolanaRpcResponse()` to `@solana/rpc-…
mcintyre94 May 19, 2026
b384ab6
Add `useSubscription` for rendering based on a subscription
mcintyre94 May 19, 2026
72ffd52
Migrate `@solana/react` to depend on `@solana/kit` (peer) and re-expo…
mcintyre94 May 26, 2026
1ec107f
Add `RpcSendable` / `RpcSubscribable` duck-types and loosen reactive-…
mcintyre94 May 27, 2026
34171eb
Add `useTrackedData` for an RPC subscription seeded by an RPC fetch
mcintyre94 May 26, 2026
3b7b57c
Add `@solana/react/swr` subpath with `useRequestSwr`
mcintyre94 May 27, 2026
395ad3f
Add `useSubscriptionSwr` to the `@solana/react/swr` adapter
mcintyre94 May 27, 2026
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
23 changes: 23 additions & 0 deletions .changeset/blue-webs-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@solana/subscribable': major
'@solana/rpc-subscriptions-spec': major
'@solana/kit': major
---

Add `withSignal()` to `ReactiveStreamStore` for per-connection cancellation, replacing the construction-time `abortSignal` option. Mirrors the action store's per-dispatch `withSignal()` pattern — callers attach a per-connection signal at the call site instead of baking one into the store.

```ts
const store = createReactiveStoreFromDataPublisherFactory({
createDataPublisher: signal => transport({ signal, ...plan }),
dataChannelName: 'notification',
errorChannelName: 'error',
});
// Per-connection timeout — fresh clock per attempt:
store.withSignal(AbortSignal.timeout(30_000)).connect();
```

`store.withSignal(signal)` returns a thin wrapper exposing `connect()` that composes the caller-provided signal with the per-connection inner controller via `AbortSignal.any`. Aborting the caller's signal surfaces the abort reason on state as `{ status: 'error' }`; supersession via the internal controller (a newer `connect()` or `reset()`) stays silent so the newer call owns state. The "permanent kill switch" pattern is expressible by binding once: `const killable = store.withSignal(killCtrl.signal); killable.connect();`. After `killCtrl.abort()`, every `killable.connect()` short-circuits to error.

`createDataPublisher` is widened from `() => Promise<DataPublisher>` to `(signal: AbortSignal) => Promise<DataPublisher>`. The store passes the composed per-connection signal to the factory so the underlying transport can stop on per-connection abort, not just the stream-store's listeners. Existing no-arg factories still satisfy the new shape — TypeScript allows fewer parameters than the declared type.

The construction-time `abortSignal` option on `createReactiveStoreFromDataPublisherFactory`, `createReactiveStoreWithInitialValueAndSlotTracking`, and `PendingRpcSubscriptionsRequest.reactiveStore()` is removed. Callers wanting a long-lived kill switch use the bind-once `withSignal` pattern. `ReactiveStreamSource<T>.reactiveStore()` is now parameter-less (mirrors `ReactiveActionSource<T>.reactiveStore()`).
25 changes: 25 additions & 0 deletions .changeset/bumpy-seas-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@solana/subscribable': major
'@solana/rpc-subscriptions-spec': major
'@solana/kit': major
---

Drop auto-connect from `ReactiveStreamStore`; callers explicitly invoke `connect()` to open the underlying stream. Mirrors the action store's caller-driven `dispatch()` pattern — the store is a state machine that callers orchestrate, not a self-starting subscription.

The factory variant returned by `createReactiveStoreFromDataPublisherFactory` now starts in `status: 'idle'`. Call `store.connect()` to open the stream; from `idle`, the store transitions through `loading` → `loaded` (or `error`). A subsequent `connect()` from any non-idle status transitions through `retrying` while preserving the last known value. A new `reset()` method aborts the current connection and returns the store to `idle` without permanently killing it — natural for React effect cleanup.

```ts
const store = createReactiveStoreFromDataPublisherFactory({
abortSignal,
createDataPublisher,
dataChannelName: 'notification',
errorChannelName: 'error',
});
store.connect(); // opens the stream — previously this happened on construction
```

`retry()` is now deprecated; it remains as an error-only alias for `connect()`. Migrate to calling `connect()` directly. Code that previously relied on `retry()` being a no-op when the store was not in `error` state should add an explicit `if (status === 'error') store.connect();` guard at the call site.

`createReactiveStoreFromDataPublisher` (the deprecated non-factory variant accepting a ready-made `DataPublisher`) is removed. Its only documented use was as a backwards-compatibility alias behind `PendingRpcSubscriptionsRequest.reactive()`, which is also removed in this release. Migrate to the factory variant — wrap a ready-made publisher in `() => Promise.resolve(publisher)` if needed — and use `reactiveStore()` for RPC subscriptions.

`createReactiveStoreWithInitialValueAndSlotTracking` in `@solana/kit` no longer fires the RPC request on construction — call `store.connect()` to start it, or wrap in a `useEffect` that calls `connect()` on mount and `reset()` on cleanup. The store starts in `status: 'idle'` and follows the same lifecycle as the underlying stream store.
10 changes: 10 additions & 0 deletions .changeset/cool-coats-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@solana/subscribable': minor
---

Add `store.withSignal(signal)` on `ReactiveActionStore` for attaching a caller-provided `AbortSignal` to a dispatch. The method returns a thin wrapper exposing only `dispatch` / `dispatchAsync`; the supplied signal is composed with the store's internal per-dispatch controller via `AbortSignal.any`, so aborting either cancels the in-flight call and surfaces the abort reason on state. The bare `dispatch` / `dispatchAsync` signatures are unchanged — this is additive.

Two common patterns the wrapper enables:

- **Per-attempt timeout.** `store.withSignal(AbortSignal.timeout(5_000)).dispatch(args)` — a fresh clock per call. Different call sites can pass different timeouts.
- **Shared kill switch.** Hold one `AbortController`, bind the wrapper once (`const killable = store.withSignal(killCtrl.signal)`), and use `killable.dispatch(...)` everywhere. Aborting the controller cancels the current call and makes future calls on the wrapper start aborted.
23 changes: 23 additions & 0 deletions .changeset/empty-deserts-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@solana/rpc-spec': minor
'@solana/rpc-subscriptions-spec': minor
'@solana/kit': minor
---

Add `RpcSendable<T>` and `RpcSubscribable<T>` structural duck-types alongside the concrete `PendingRpcRequest<T>` and `PendingRpcSubscriptionsRequest<T>`. Both new types are intentionally narrower — `RpcSendable<T>` covers just `send({ abortSignal })` and `RpcSubscribable<T>` covers just `subscribe({ abortSignal })` — so consumers (higher-level kit helpers, plugin-authored wrappers, test mocks) can accept request-like objects without taking on the full `{ reactiveStore, send }` / `{ reactiveStore, subscribe }` shape.

```ts
import type { RpcSendable, RpcSubscribable } from '@solana/kit';

type RpcSendable<TResponse> = {
send(options?: RpcSendOptions): Promise<TResponse>;
};

type RpcSubscribable<TNotification> = {
subscribe(options: RpcSubscribeOptions): Promise<AsyncIterable<TNotification>>;
};
```

`PendingRpcRequest<T>` still structurally satisfies `RpcSendable<T>`; `PendingRpcSubscriptionsRequest<T>` still satisfies `RpcSubscribable<T>`. No change at producer boundaries (`rpc.<method>(...)` / `rpcSubscriptions.<method>(...)` returns).

Loosens the `rpcRequest` / `rpcSubscriptionRequest` fields on `CreateReactiveStoreWithInitialValueAndSlotTrackingConfig` (exported) and the internal config for `createAsyncGeneratorWithInitialValueAndSlotTracking` from `PendingRpcRequest<...>` / `PendingRpcSubscriptionsRequest<...>` to `RpcSendable<...>` / `RpcSubscribable<...>` — both primitives only ever call `.send()` and `.subscribe()` on those inputs, so they now accept any compatible duck-type. Existing callers passing concrete `Pending*Request` objects continue to work.
20 changes: 20 additions & 0 deletions .changeset/fiery-regions-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@solana/rpc-spec': major
---

`PendingRpcRequest.reactiveStore()` no longer auto-fires the request on creation. It now returns a `ReactiveActionStore` in the `idle` state; the caller is responsible for the initial `dispatch()`.

This brings `reactiveStore()` in line with `createReactiveActionStore(fn)` (which also does not auto-fire) and removes the special-case at the start of the store's lifecycle. The previous auto-fire created an asymmetry around per-attempt cancellation: the initial request had no caller-visible dispatch site, so attaching an `AbortSignal` to that one specific attempt required a separate option distinct from the mechanism for all later attempts. Without auto-fire, every dispatch is the caller's, and signal attachment is uniform.

Migration:

```ts
// Before:
const store = rpc.getAccountInfo(address).reactiveStore();
// request was already in flight

// After:
const store = rpc.getAccountInfo(address).reactiveStore();
store.dispatch();
// request is now in flight
```
11 changes: 11 additions & 0 deletions .changeset/four-pots-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@solana/react': minor
---

Add `useAction` — a React hook that bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset` and supersede-on-second-call semantics. Built on `createReactiveActionStore` from `@solana/subscribable`.

The wrapped function receives a fresh `AbortSignal` per `send(...)`. Calling `dispatch` again while a prior call is in flight aborts the first; awaiters of the superseded call see a rejection with an `AbortError` filterable via `isAbortError` from `@solana/promises`. `data` from a prior `success` persists through subsequent `running` states for stale-while-revalidate UX; only `reset()` clears it.

`fn` is held in a ref synced to the latest render's closure, so values it captures (form state, route params, etc.) are always fresh on each new `send(...)` without the caller needing to maintain a `deps` array. In-flight calls are unaffected — they continue with the closure they captured at dispatch time. Matches the convention used by `useMutation` in TanStack Query and `useWriteContract` in wagmi.

The shared `ActionResult<TArgs, TResult>` type is also exported so plugin hooks can declare their return shape against it.
24 changes: 24 additions & 0 deletions .changeset/fresh-eggs-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@solana/react': minor
---

Add `useRequest` — a React hook for one-shot async reads. Pass either an async function `(signal) => Promise<T>` or a memoized `ReactiveActionSource<T>` (satisfied by `PendingRpcRequest`). The hook fires the call on mount, re-fires whenever the source identity changes, and aborts the in-flight call on cleanup.

```tsx
// `ReactiveActionSource` (e.g. `PendingRpcRequest`):
const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]);
const { data, error, refresh } = useRequest(source);

// Bare async function:
const fetcher = useCallback(
(signal: AbortSignal) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
[userId],
);
const { data, error, refresh } = useRequest(fetcher);
```

The result reports `status` as one of `fetching | success | error | disabled`. A request in flight is always `fetching`; inspect `data` and `error` to know what stale content (if any) is available to render alongside a spinner — first attempt has neither, a refresh after a prior outcome carries one or both forward. Pass `null` for the source to gate the request off — useful while inputs aren't yet known. The result then reports `status: 'disabled'`.

Optional `getAbortSignal: () => AbortSignal` is a factory invoked on every attempt (initial fire + every `refresh()`). Each attempt gets a fresh signal that's composed with the store's internal per-dispatch controller via `AbortSignal.any`. The natural use is per-attempt timeouts: `getAbortSignal: () => AbortSignal.timeout(5_000)` gives every attempt its own 5-second clock that resets on refresh. The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. `refresh()` also accepts an optional `{ abortSignal }` override to replace the factory for one specific attempt.

The new `RequestResult<T>` and `UseRequestOptions` types are exported alongside the hook so plugin hooks built on top can declare their return shape against them.
14 changes: 14 additions & 0 deletions .changeset/humble-coins-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@solana/react': minor
---

Add `useSubscriptionSwr(key, source, options?)` to the `@solana/react/swr` subpath — the SWR-backed counterpart to `useSubscription`. Routes a `ReactiveStreamSource<T>` through SWR's subscription cache (`useSWRSubscription`).

```tsx
import { useSubscriptionSwr } from '@solana/react/swr';

const { data } = useSubscriptionSwr(['account', address], client.rpcSubscriptions.accountNotifications(address));
// For envelope sources: data is `SolanaRpcResponse<{ lamports: bigint }> | undefined`
```

`data` is the notification exactly as the source emits it — no unwrapping. For RPC subscriptions that emit `SolanaRpcResponse<U>` envelopes, read the inner value at `data.value` and the slot at `data.context.slot`. For raw notifications (slot/logs/root), `data` is the raw shape. Mirrors core `useSubscription`'s result so moving between the two is a pure swap. Pass `null` for either `key` or `source` to disable. Options accept SWR's config plus `getAbortSignal` for per-connection signals.
14 changes: 14 additions & 0 deletions .changeset/icy-loops-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@solana/react': minor
'@solana/errors': minor
---

Add `ClientProvider`, `useClient`, and `useClientCapability` — the Kit client context layer for React.

`ClientProvider` publishes a caller-owned Kit client to its subtree. Required by `useClient`, `useClientCapability`, and any plugin-specific hook that depends on a client capability — generic primitives like `useAction` work against arbitrary async functions and don't need a provider. The provider accepts both synchronous clients and promise-returning ones — when given a promise (e.g. `createClient().use(asyncPlugin())`), it suspends via the nearest `<Suspense>` boundary until the client resolves. On React 19 it delegates to `React.use(promise)`; on React 18 an internal thrown-promise shim, keyed by promise identity, honours the same contract.

`useClient<TClient>()` is the basic context accessor. Defaults to the base `Client` shape; callers who know a specific plugin is installed may widen the type via the generic. Throws a new `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_PROVIDER` when called outside a provider.

`useClientCapability<TClient>({ capability, hookName, providerHint })` runtime-checks that the requested capability (or capabilities) is installed on the client and throws `SOLANA_ERROR__REACT__MISSING_CAPABILITY` — surfacing the calling `hookName` and a `providerHint` — when it isn't. Plugin-hook authors use this to fail loudly at mount instead of letting a missing plugin surface later as `undefined`.

Two new error codes (`SOLANA_ERROR__REACT__MISSING_PROVIDER`, `SOLANA_ERROR__REACT__MISSING_CAPABILITY`) are reserved in the `[9000000-9000999]` range.
23 changes: 23 additions & 0 deletions .changeset/icy-sites-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@solana/react': minor
---

Add `useSubscription` — a React hook for subscription-based live data. Pass a `ReactiveStreamSource<T>` (satisfied by `PendingRpcSubscriptionsRequest`) and the hook opens the subscription on mount, re-opens whenever the source identity changes, and tears it down on unmount.

```tsx
function AccountBalance({ address }: { address: Address }) {
const client = useClient<ClientWithRpcSubscriptions<AccountNotificationsApi>>();
const source = useMemo(() => client.rpcSubscriptions.accountNotifications(address), [client, address]);
const { data, error, reconnect } = useSubscription(source);
if (error) return <button onClick={reconnect}>Reconnect</button>;
return <p>{data ? `${data.value.lamports} lamports at slot ${data.context.slot}` : 'Connecting…'}</p>;
}
```

The result reports `status` as one of `loading | loaded | error | disabled`. `data` is the notification exactly as the source emits it — no unwrapping or reshaping. For RPC subscriptions that emit `SolanaRpcResponse<U>` (account/program/signature), read the inner value at `data.value` and the slot at `data.context.slot`; for raw notifications (slot/logs/root) `data` is the raw shape. Pass `null` for the source to gate the subscription off — useful while inputs aren't yet known. The result then reports `status: 'disabled'`. After a notification arrives, an error transitions to `status: 'error'` while preserving the stale `data`; `reconnect()` returns to `loading` (preserving stale `data` and `error` for stale-while-revalidate) before settling on `loaded` or a fresh `error`.

Optional `getAbortSignal: () => AbortSignal` is a factory invoked on every connection (initial subscribe + every `reconnect()`). Each connection gets a fresh signal that the underlying store composes with its per-connection controller via `AbortSignal.any`. The natural use is per-connection timeouts: `getAbortSignal: () => AbortSignal.timeout(30_000)` gives every connection its own 30-second clock that resets on reconnect. The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. `reconnect()` also accepts an optional `{ abortSignal }` override to replace the factory for one specific attempt (presence-based: omit to use the factory, `{ abortSignal: signal }` to override, `{ abortSignal: undefined }` to opt out).

The hook mirrors `useRequest`'s structure exactly: construct the lazy store via `useMemo`, fire `store.connect()` in a `useEffect`, tear down via `store.reset()` in cleanup. Same StrictMode-safe lifecycle, same vocabulary, same per-call signal API. SSR-safe — on the server the connect effect doesn't run, so the store stays `idle` and the hook reports `status: 'loading'`; first client render hydrates from the same paint and commits the connect.

`SubscriptionResult<T>` and `UseSubscriptionOptions` are exported alongside the hook so plugin hooks built on top can declare their return shape against them.
13 changes: 13 additions & 0 deletions .changeset/mighty-clouds-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@solana/react': minor
---

Add `@solana/react/swr` subpath with `useRequestSwr(key, source, options?)` — the SWR-backed counterpart to `useRequest`. Same source shape (`ReactiveActionSource<T>` or `(signal) => Promise<T>`); returns SWR's native `SWRResponse<T>`. Pass `null` for either `key` or `source` to disable. Requires `swr@^2` as an optional peer dependency.

```tsx
import { useRequestSwr } from '@solana/react/swr';

const { data } = useRequestSwr(['epochInfo'], client.rpc.getEpochInfo());
```

Options accept any `SWRConfiguration` field plus the Kit-only `getAbortSignal: () => AbortSignal` (same option as `useRequest`), which threads a per-attempt signal into the source — typically a timeout via `AbortSignal.timeout()`. Use SWR's `result.mutate()` to re-fire on demand.
Loading
Loading