github anza-xyz/kit v7.0.0

4 hours ago

@solana/kit

v7.0.0 (2026-06-30)

Major Changes

  • [@solana/codecs-data-structures] #1683 99667a1 Thanks @oritwoen! - Align the return types of the union, predicate, and pattern-match codecs so that a fixed-size result is only exposed when every branch is fixed-size and of the same statically-known size. Branches whose sizes are unequal or not statically known now widen from FixedSize* to VariableSize* (when at least one branch is variable-size) or to a plain Encoder/Decoder/Codec (when the branches are fixed-size but their sizes are not statically comparable), matching what these codecs actually produce at runtime.

    This is a type-only change with no runtime impact, but it is breaking in two ways:

    • Return types change. Consumers that relied on the previous (unsound) fixed-size typing — e.g. reading .fixedSize, or passing the result where a FixedSize* is required — will need to adjust.
    • The predicate and pattern-match signatures changed. Their value/output type is now inferred from the branch encoders, decoders, or codecs rather than from an explicit type argument. Passing an explicit type argument no longer sets the branch domain: getPatternMatchEncoder<MyType>([...]) (and the decoder/codec equivalents) now fails to compile, and getPredicateEncoder<MyType>(...) silently degrades its return to a plain Encoder<MyType>. Instead, type the branch predicates' parameters (e.g. (value: MyType) => ...) and let the return type be inferred.
  • [@solana/instruction-plans] #1723 069d56d Thanks @mcintyre94! - Add configurable instruction-count limits to transaction planners and message packers, and default planned and packed transaction messages to 16 instructions. The planner limit applies to the final transaction message, including instructions returned by createTransactionMessage or added by onTransactionMessageUpdated, and can be overridden when creating a planner or for an individual planning call.

    This is useful because Solana limits transactions to 64 instructions, including inner instructions. Kit does not know how many inner instructions each instruction will require when executed. The default of 16 assumes an average of 3 additional inner instructions per top-level instruction.

    When a transaction message reaches this configured ceiling, the planner and message packer throw the new SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED error rather than the SOLANA_ERROR__TRANSACTION__TOO_MANY_INSTRUCTIONS error reserved for the hard 64-instruction limit, so the configurable soft limit is distinguishable from the format-enforced one. Throws SOLANA_ERROR__INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION is the configured max is invalid (not a positive integer, or greater than 64).

    Configure a maximum for every plan created by a transaction planner:

    const transactionPlanner = createTransactionPlanner({
        createTransactionMessage,
        maxInstructionsPerTransaction: 32,
    });

    Override the maximum for an individual planning request:

    const transactionPlan = await transactionPlanner(instructionPlan, {
        maxInstructionsPerTransaction: 8,
    });

    Override the maximum when packing a message directly:

    const packedTransactionMessage = messagePacker.packMessageToCapacity(transactionMessage, {
        maxInstructions: 32,
    });

    BREAKING CHANGES

    Transaction planners and message packers now default to 16 instructions per transaction. Plans and direct message packer calls that previously fit 17 to 64 top-level instructions in one transaction message may now be split into multiple transaction messages. Apps that depend on larger single-transaction plans can preserve the previous top-level instruction limit by configuring maxInstructionsPerTransaction: 64 on transaction planners or maxInstructions: 64 on direct message packer calls; the hard transaction-message limit of 64 top-level instructions still applies.

     const transactionPlanner = createTransactionPlanner({
         createTransactionMessage,
    +    maxInstructionsPerTransaction: 64,
     });
    -const packedTransactionMessage = messagePacker.packMessageToCapacity(transactionMessage);
    +const packedTransactionMessage = messagePacker.packMessageToCapacity(transactionMessage, { maxInstructions: 64 });
  • [@solana/kit, @solana/rpc-subscriptions-spec, @solana/subscribable] #1663 d09718d Thanks @mcintyre94! - 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.

    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()).

  • [@solana/kit, @solana/rpc-subscriptions-spec, @solana/subscribable] #1662 fa04323 Thanks @mcintyre94! - 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 loadingloaded (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.

    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.

  • [@solana/kit] #1708 03000e5 Thanks @mcintyre94! - createReactiveStoreWithInitialValueAndSlotTracking now consumes its two inputs as reactive sources rather than as request objects it calls send() / subscribe() on directly. The rpcRequest / rpcSubscriptionRequest config fields (and their rpcValueMapper / rpcSubscriptionValueMapper) are replaced by initialValueSource: ReactiveActionSource<...> / streamSource: ReactiveStreamSource<...> (with initialValueMapper / streamValueMapper).

    Each source is consumed via its reactiveStore() method, so the helper reuses ReactiveActionStore / ReactiveStreamStore primitives. PendingRpcRequest satisfies ReactiveActionSource and PendingRpcSubscriptionsRequest satisfies ReactiveStreamSource, so callers can still pass eg. rpc.getBalance(addr) / rpcSubscriptions.accountNotifications(addr) results directly.

    const balanceStore = createReactiveStoreWithInitialValueAndSlotTracking({
        initialValueSource: rpc.getBalance(myAddress, { commitment: 'confirmed' }),
        initialValueMapper: lamports => lamports,
        streamSource: rpcSubscriptions.accountNotifications(myAddress),
        streamValueMapper: ({ lamports }) => lamports,
    });
    balanceStore.withSignal(AbortSignal.timeout(60_000)).connect();
  • [@solana/kit, @solana/subscribable] #1677 a198b5c Thanks @mcintyre94! - Collapse loading and retrying into a single loading status on ReactiveStreamStore, mirroring the action store's running (which is itself the merged "first call vs subsequent call" state). data and error are preserved through loading for stale-while-revalidate — UI can render the prior outcome alongside an in-flight reconnect.

    ReactiveState<T> drops the retrying variant. loading widens from { data: undefined, error: undefined } to { data: T | undefined, error: unknown }. Both createReactiveStoreFromDataPublisherFactory and createReactiveStoreWithInitialValueAndSlotTracking now transition every connect() through loading (preserving currentState.data and currentState.error); a subsequent loaded clears error, a subsequent error replaces it.

    // Previously:
    { status: 'error', data: lastValue, error: caughtError }
    // connect() →
    { status: 'retrying', data: lastValue, error: undefined }  // error cleared, separate status
    
    // Now:
    { status: 'error', data: lastValue, error: caughtError }
    // connect() →
    { status: 'loading', data: lastValue, error: caughtError }  // error preserved, unified status

    Migration: replace status === 'retrying' checks with status === 'loading' && data !== undefined (or just status === 'loading' if you don't need to distinguish first-load vs reconnect — the SWR pattern lets you render whatever is in data regardless).

  • [@solana/kit, @solana/plugin-core] #1786 6947740 Thanks @mcintyre94! - Remove deprecated getMinimumBalanceForRentExemption and createEmptyClient.

    BREAKING CHANGES

    Removed getMinimumBalanceForRentExemption from @solana/kit. The minimum balance for an account is being actively reduced (see SIMD-0437) and is expected to become dynamic in future Solana upgrades (see SIMD-0194 and SIMD-0389), so a hardcoded local computation can no longer return accurate results. Use the getMinimumBalanceForRentExemption RPC method or a ClientWithGetMinimumBalance plugin instead.

    - import { getMinimumBalanceForRentExemption } from '@solana/kit';
    - const rentExemptLamports = getMinimumBalanceForRentExemption(82n);
    + const { value: rentExemptLamports } = await rpc.getMinimumBalanceForRentExemption(82n).send();

    Removed createEmptyClient from @solana/plugin-core. Use createClient, which behaves identically and additionally accepts an optional initial value.

    - import { createEmptyClient } from '@solana/plugin-core';
    - const client = createEmptyClient();
    + import { createClient } from '@solana/plugin-core';
    + const client = createClient();
  • [@solana/kit, @solana/react, @solana/subscribable] #1780 acec0be Thanks @mcintyre94! - Streamline the ReactiveStreamStore contract by removing deprecated members and unifying its state accessor with ReactiveActionStore. The getUnifiedState() method has been renamed to getState(), and the deprecated value-only getState(), getError(), and retry() members along with the ReactiveStore type alias have been removed.

    BREAKING CHANGES

    getUnifiedState() renamed to getState(). The unified { data, error, status } snapshot accessor is now simply getState(), matching ReactiveActionStore.getState().

    - const state = useSyncExternalStore(store.subscribe, store.getUnifiedState);
    + const state = useSyncExternalStore(store.subscribe, store.getState);

    Removed the deprecated value-only getState() and getError(). Read the value and error off the unified snapshot instead.

    - const data = store.getState();
    - const error = store.getError();
    + const { data, error } = store.getState();

    Removed retry(). Use connect(), which always (re)connects regardless of status. Wrap it with a status guard if you need the error-only behavior.

    - store.retry();
    + if (store.getState().status === 'error') store.connect();

    Removed the ReactiveStore type alias. Use ReactiveStreamStore directly.

    - import type { ReactiveStore } from '@solana/subscribable';
    + import type { ReactiveStreamStore } from '@solana/subscribable';
  • [@solana/rpc-api, @solana/rpc-parsed-types, @solana/rpc-transformers] #1795 8d3bbf1 Thanks @mcintyre94! - Update RPC and parsed-account types to match Agave 4.1.0, and surface basis-points commission and vote-latency fields as numbers instead of bigints.

    BREAKING CHANGES

    Parsed vote-account commissions and vote latency are now number instead of bigint. Agave returns these as small bounded integers (u16/u8), so kit no longer upcasts them. This affects blockRevenueCommissionBps, inflationRewardsCommissionBps, and each vote's latency on JsonParsedVoteAccount.

    - const bps: bigint = voteAccount.inflationRewardsCommissionBps;
    + const bps: number = voteAccount.inflationRewardsCommissionBps;

    The parsed rent sysvar is now a union of the current and pre-4.1.0 shapes. Agave 4.1.0 reshaped the rent sysvar from { burnPercent, exemptionThreshold, lamportsPerByteYear } to { lamportsPerByte }. The type is now a union of both, so consumers must narrow before accessing the legacy fields. Narrow on the presence of lamportsPerByte (current) versus lamportsPerByteYear (deprecated).

    - const perByteYear = rent.info.lamportsPerByteYear;
    + const perByte = 'lamportsPerByte' in rent.info
    +     ? rent.info.lamportsPerByte
    +     : rent.info.lamportsPerByteYear;

    warmupCooldownRate on the parsed stake delegation is now optional. Agave 4.1.0 removed it from the parsed output, so it is only present on accounts fetched from validators running earlier versions. It is marked @deprecated.

    Additionally, getVoteAccounts now includes an optional inflationRewardsCommissionBps field (added by Agave 4.1.0; absent on older validators), and the parsed stake-config account fields (slashPenalty, warmupCooldownRate) are marked @deprecated because the stake config program is no longer recognized by the RPC's JSON parser as of Agave 4.1.0 (such accounts now fall back to annotated base64).

    The GraphQL SysvarRentAccount type gains a nullable lamportsPerByte field to match the reshaped rent sysvar in Agave 4.1.0. The legacy burnPercent, exemptionThreshold, and lamportsPerByteYear fields are retained (and remain nullable) for validators running earlier versions.

  • [@solana/rpc-api] #1803 cab6d7e Thanks @mcintyre94! - Always surface replacementBlockhash on the simulateTransaction response. Agave v3.x validators unconditionally include this field, setting it to null when replaceRecentBlockhash was not true. The field now lives on the base response type as TransactionBlockhashLifetime | null, and is narrowed to a non-null TransactionBlockhashLifetime only on the overloads where replaceRecentBlockhash: true.

    BREAKING CHANGES

    replacementBlockhash is now always present on the simulateTransaction response. Previously the field was only present on the type when replaceRecentBlockhash was true. It is now always present, typed as TransactionBlockhashLifetime | null, with null indicating that no blockhash was replaced. Code that relied on the field being absent (e.g. to discriminate the response shape) must instead check for null.

    const { value } = await rpc.simulateTransaction(tx, { encoding: 'base64' }).send();
    - // `value.replacementBlockhash` was not present on the type
    + // `value.replacementBlockhash` is now `TransactionBlockhashLifetime | null` (null here)
  • [@solana/rpc-spec] #1628 a6783e0 Thanks @mcintyre94! - 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:

    // 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

Minor Changes

  • [@solana/codecs-data-structures] #1731 ec4d3ef Thanks @kh0ra! - Add createDependentStructDecoder, a fluent builder for a struct decoder whose later fields may depend on the decoded values of earlier ones. Each call to field adds a name and either a static Decoder or a factory that receives a frozen snapshot of the fields decoded so far. Calling build produces a FixedSizeDecoder when every field added to the builder is itself a FixedSizeDecoder, and a VariableSizeDecoder otherwise.

    This is useful for binary formats where a count, version, or discriminator that appears near the start of the struct controls how a later field must be parsed, such as the per-instruction headers in a v1 transaction message.

    const decoder = createDependentStructDecoder()
        .field('count', getU8Decoder())
        .field('values', fields => getArrayDecoder(getU32Decoder(), { size: fields.count }))
        .build();
  • [@solana/errors] #1719 3014977 Thanks @mcintyre94! - Add the SOLANA_ERROR__REACT__SUBSCRIPTION_CLOSED_WITHOUT_ERROR error code. useSubscriptionSWR now surfaces this SolanaError when the underlying store reaches an error state without an error value (e.g. a DataPublisher emitting undefined on its error channel, or controller.abort(null)), instead of passing the nullish value to SWR's next — which would be treated as a success and silently wipe the cached data.

  • [@solana/errors, @solana/kit, @solana/rpc-api, @solana/transaction-introspection] #1611 772b82c Thanks @amilz! - Add @solana/transaction-introspection, a new package that bridges a getTransaction response and the auto-generated @solana-program/* parseXInstruction clients. Decodes the transaction (encoding: 'base64', 'base58', or 'json'), resolves account indices against static + ALT-loaded addresses, normalizes inner instructions from meta.innerInstructions, and exposes walkInstructions to enumerate every instruction in display order — each outer instruction followed by its inner instructions — with a trace recording its location. Each returned instruction is a ResolvedInstruction & { trace } directly usable with isInstructionForProgram from @solana/instructions and with the auto-generated identifyXInstruction / parseXInstruction helpers. Supports legacy, v0, and v1 compiled transaction messages. Re-exported from @solana/kit.

    import { createSolanaRpc, signature } from '@solana/kit';
    import { isInstructionForProgram } from '@solana/instructions';
    import { decodeTransactionFromRpcResponse, walkInstructions } from '@solana/transaction-introspection';
    import { identifyTokenInstruction, TOKEN_PROGRAM_ADDRESS, TokenInstruction } from '@solana-program/token';
    
    const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
    const rpcTx = await rpc
        .getTransaction(signature(txid), {
            commitment: 'confirmed',
            encoding: 'base64',
            maxSupportedTransactionVersion: 0,
        })
        .send();
    if (!rpcTx) throw new Error(`Transaction ${txid} not found`);
    
    const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(rpcTx);
    
    for (const ix of walkInstructions({ compiledMessage, loadedAddresses, meta: rpcTx.meta })) {
        if (!isInstructionForProgram(ix, TOKEN_PROGRAM_ADDRESS)) continue;
        if (identifyTokenInstruction(ix) === TokenInstruction.SyncNative) {
            console.log('SyncNative found at', ix.trace);
        }
    }

    @solana/rpc-api now exports the non-null getTransaction response shapes as named types (GetTransactionApiResponseBase58, GetTransactionApiResponseBase64, GetTransactionApiResponseJson, GetTransactionApiResponseJsonParsed), which decodeTransactionFromRpcResponse accepts as inputs. @solana/errors gains SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE plus a new TRANSACTION_INTROSPECTION domain (SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION, SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE).

  • [@solana/errors, @solana/react] #1607 e193711 Thanks @mcintyre94! - 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.

  • [@solana/kit] #1706 9063658 Thanks @mcintyre94! - Migrate @solana/react to depend on @solana/kit as a peer dependency (replacing its individual workspace sub-package deps) and re-export @solana/subscribable from @solana/kit so React consumers have a single import root. @solana/promises remains as a direct dep — it's a small utility that isn't part of Kit's public surface.

    For @solana/react users:

    • @solana/kit must now be installed alongside @solana/react.
    • Apps that already use both get a single deduplicated @solana/kit instance — important for anything relying on shared types or instanceof SolanaError checks.
    • Kit can be bumped independently of React within the peer range.

    For @solana/kit users:

    • ReactiveStreamSource, ReactiveStreamStore, ReactiveActionSource, ReactiveActionStore, ReactiveState, createReactiveActionStore, createReactiveStoreFromDataPublisherFactory, DataPublisher and the rest of @solana/subscribable's surface are now reachable directly through @solana/kit.
  • [@solana/react] #1612 08777cf Thanks @mcintyre94! - Add useAction — a React hook that bridges any async function into a tracked action with dispatch / dispatchAsync / status / data / error / reset and supersede-on-second-call semantics. Built on createReactiveActionStore from @solana/subscribable.

    The wrapped function receives a fresh AbortSignal per dispatch. dispatch(...) is fire-and-forget — it returns void, never throws, and is the variant to wire into UI event handlers, with outcomes read off status / data / error. dispatchAsync(...) returns a promise for imperative callers that need the resolved value. Calling either again while a prior call is in flight aborts the first; awaiters of a superseded dispatchAsync 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 dispatch 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.

  • [@solana/react] #1619 fd6bdef Thanks @mcintyre94! - 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.

    // `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.

  • [@solana/react] #1719 3014977 Thanks @mcintyre94! - 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).

    import { useSubscriptionSWR } from '@solana/react/swr';
    
    const { data } = useSubscriptionSWR(['account', address], client.rpcSubscriptions.accountNotifications(address));

    data is the notification exactly as the source emits it. Pass null for either key or source to disable. Options accept SWR's config plus getAbortSignal for an abort signal.

  • [@solana/react] #1702 3a92f37 Thanks @mcintyre94! - 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.

    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.

  • [@solana/react] #1713 587ec07 Thanks @mcintyre94! - 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.

    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.

  • [@solana/react] #1707 da42ff8 Thanks @mcintyre94! - Add useTrackedData — a React hook for an RPC subscription seeded by a one-shot RPC fetch, slot-deduped. The subscription (e.g. accountNotifications) is the primary source of live updates; the initial fetch (e.g. getBalance, getAccountInfo) provides a value to surface as soon as it resolves — typically before the first subscription notification arrives — so the loading paint is shorter than subscription-only would give you. Surfaces a unified { data, error, refresh, status } view where data is the SolanaRpcResponse<TItem> envelope that the underlying kit primitive emits — the primitive's type guarantees the envelope shape, so callers can read data.value and data.context.slot directly without a runtime check. The underlying store slot-dedupes between the two sources — out-of-order arrivals never regress the surfaced value (older slots are dropped silently, so a stale RPC response can't overwrite a fresher subscription notification).

    function AccountBalance({ address }: { address: Address }) {
        const client = useClient<ClientWithRpc<GetBalanceApi> & ClientWithRpcSubscriptions<AccountNotificationsApi>>();
        const spec = useMemo(
            () => ({
                rpcRequest: client.rpc.getBalance(address),
                rpcSubscriptionRequest: client.rpcSubscriptions.accountNotifications(address),
                rpcValueMapper: (lamports: bigint) => lamports,
                rpcSubscriptionValueMapper: ({ lamports }: { lamports: bigint }) => lamports,
            }),
            [client, address],
        );
        const { data, error, refresh } = useTrackedData(spec);
        if (error) return <button onClick={refresh}>Retry</button>;
        return <p>{data ? `${data.value} lamports at slot ${data.context.slot}` : 'Loading…'}</p>;
    }

    The result reports status as one of loading | loaded | error | disabled. Pass null for the spec to gate the work off — useful while inputs aren't yet known (e.g. an address that hasn't been selected). After a notification arrives, an error transitions to status: 'error' while preserving the stale data (envelope intact); refresh() re-runs both the initial RPC and the subscription, returns status to loading (preserving stale data and error for stale-while-revalidate), and settles on loaded or a fresh error.

    Optional getAbortSignal: () => AbortSignal is a factory invoked on every attempt (initial run + every refresh()). Each attempt gets a fresh signal that the underlying store composes with its per-attempt controller via AbortSignal.any. The natural use is per-attempt timeouts: getAbortSignal: () => AbortSignal.timeout(30_000) gives every attempt its own 30-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 (presence-based: omit to use the factory, { abortSignal: signal } to override, { abortSignal: undefined } to opt out).

    The hook is built on createReactiveStoreWithInitialValueAndSlotTracking from @solana/kit — the slot tracking, abort plumbing, and stale-while-revalidate behaviour live one layer down. The React surface reduces to useSyncExternalStore glue plus the per-attempt signal API. The Kit primitive's config type is re-shaped as TrackedDataSpec<TRpcValue, TSubscriptionValue, TItem> for friendlier use-site naming; the two are mutually assignable. 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.

    TrackedDataResult<T>, TrackedDataSpec<TRpc, TSub, T>, and UseTrackedDataOptions are exported alongside the hook for plugin hooks built on top.

  • [@solana/react] #1727 c32a0f7 Thanks @mcintyre94! - Add useTrackedDataSWR(key, spec, options?) to the @solana/react/swr subpath — the SWR-backed counterpart to useTrackedData. Takes the same TrackedDataSpec and routes the unified, slot-deduped stream through SWR's useSWRSubscription.

    import { useTrackedDataSWR } from '@solana/react/swr';
    
    const { data } = useTrackedDataSWR(['balance', address], spec);
    // data is `SolanaRpcResponse<TItem> | undefined`

    data is shape SolanaRpcResponse<TItem>, because this hook requires the slot for de-duping. Mirrors core useTrackedData. Pass null for either key or spec to disable. Options accept SWR's config plus getAbortSignal for a custom abort signal.

  • [@solana/react] #1769 205af00 Thanks @mcintyre94! - Add useTrackedDataQuery to the @solana/react/query subpath. This is the TanStack Query-backed counterpart to useTrackedData: it pairs a one-shot RPC fetch with an ongoing subscription (slot-deduped) and routes the unified stream through TanStack Query's cache via experimental_streamedQuery, surfacing the SolanaRpcResponse<TItem> envelope as data. Slot dedupe spans the cache, so a refetch()'s fresh store cannot regress the cached envelope to an older slot from a lagging RPC node.

  • [@solana/react] #1759 1032a79 Thanks @mcintyre94! - Add a @solana/react/query subpath that bridges Kit's reactive primitives into TanStack Query. The new useRequestQuery(key, source, options?) hook is the TanStack Query-backed counterpart to useRequest — it accepts the same ReactiveActionSource<T> or (signal: AbortSignal) => Promise<T> source shape, routes it through TanStack's cache, and threads the query's cancellation signal (combined with the optional getAbortSignal factory) into the source. Pass a null source to disable the query (mapped to TanStack's enabled: false). @tanstack/react-query@^5 is an optional peer dependency.

  • [@solana/react] #1760 251b361 Thanks @mcintyre94! - Add useSubscriptionQuery(key, source, options?) to the @solana/react/query subpath — the TanStack Query-backed counterpart to useSubscription, for streams with no one-shot RPC fetch. It routes a long-lived stream through TanStack Query's cache via experimental_streamedQuery, so components reading the same key share one connection and the stream shows up in TanStack Query's devtools.

    import { useSubscriptionQuery } from '@solana/react/query';
    
    const { data, error } = useSubscriptionQuery(['slot'], client.rpcSubscriptions.slotNotifications());

    The source matches useSubscription: a ReactiveStreamSource<T>. The hook also accepts a raw (signal: AbortSignal) => AsyncIterable<T> factory, as experimental_streamedQuery is built on AsyncIterable. data is the raw notification — the SolanaRpcResponse envelope is not unwrapped — matching useSubscription. Pass null for source to disable (TanStack's enabled: false); call result.refetch() to reconnect. Defaults retry: false, staleTime: Infinity, and refetchOnWindowFocus: false so a focus revalidation doesn't tear down and re-open the connection.

  • [@solana/react, @solana/subscribable] #1624 1c8d215 Thanks @mcintyre94! - Preserve the last error on a ReactiveActionStore through subsequent running states, matching the existing stale-while-revalidate behavior for data. A re-dispatch after a failure now keeps the previous error visible until the new attempt resolves, mirroring how SWR and TanStack Query handle revalidation. success clears the error; reset() clears both. This also affects useAction, whose error field now persists through a new dispatch() until the new call resolves.

  • [@solana/rpc-graphql] #1795 8d3bbf1 Thanks @mcintyre94! - Update RPC and parsed-account types to match Agave 4.1.0, and surface basis-points commission and vote-latency fields as numbers instead of bigints.

    BREAKING CHANGES

    Parsed vote-account commissions and vote latency are now number instead of bigint. Agave returns these as small bounded integers (u16/u8), so kit no longer upcasts them. This affects blockRevenueCommissionBps, inflationRewardsCommissionBps, and each vote's latency on JsonParsedVoteAccount.

    - const bps: bigint = voteAccount.inflationRewardsCommissionBps;
    + const bps: number = voteAccount.inflationRewardsCommissionBps;

    The parsed rent sysvar is now a union of the current and pre-4.1.0 shapes. Agave 4.1.0 reshaped the rent sysvar from { burnPercent, exemptionThreshold, lamportsPerByteYear } to { lamportsPerByte }. The type is now a union of both, so consumers must narrow before accessing the legacy fields. Narrow on the presence of lamportsPerByte (current) versus lamportsPerByteYear (deprecated).

    - const perByteYear = rent.info.lamportsPerByteYear;
    + const perByte = 'lamportsPerByte' in rent.info
    +     ? rent.info.lamportsPerByte
    +     : rent.info.lamportsPerByteYear;

    warmupCooldownRate on the parsed stake delegation is now optional. Agave 4.1.0 removed it from the parsed output, so it is only present on accounts fetched from validators running earlier versions. It is marked @deprecated.

    Additionally, getVoteAccounts now includes an optional inflationRewardsCommissionBps field (added by Agave 4.1.0; absent on older validators), and the parsed stake-config account fields (slashPenalty, warmupCooldownRate) are marked @deprecated because the stake config program is no longer recognized by the RPC's JSON parser as of Agave 4.1.0 (such accounts now fall back to annotated base64).

    The GraphQL SysvarRentAccount type gains a nullable lamportsPerByte field to match the reshaped rent sysvar in Agave 4.1.0. The legacy burnPercent, exemptionThreshold, and lamportsPerByteYear fields are retained (and remain nullable) for validators running earlier versions.

  • [@solana/rpc-types] #1678 2c47363 Thanks @mcintyre94! - Add UnwrapRpcResponse<T> type and isSolanaRpcResponse() runtime helper alongside SolanaRpcResponse. Use them to detect and unwrap notifications that may or may not be wrapped in a SolanaRpcResponse envelope.

    UnwrapRpcResponse<T> is a conditional type:

    type UnwrapRpcResponse<T> = T extends SolanaRpcResponse<infer U> ? U : T;

    isSolanaRpcResponse() is a type guard that validates the envelope shape by checking context.slot: bigint and the presence of value, leaving room for additional envelope fields without changing the guard's contract. The narrowed type is SolanaRpcResponse<UnwrapRpcResponse<T>>, so callers don't need to spell out the inner type separately.

    import { isSolanaRpcResponse } from '@solana/rpc-types';
    
    function lift<T>(notification: T) {
        if (isSolanaRpcResponse(notification)) {
            return { slot: notification.context.slot, value: notification.value };
        }
        return { slot: undefined, value: notification };
    }
  • [@solana/subscribable] #1614 3de3dda Thanks @mcintyre94! - 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.

Patch Changes

  • [@solana/errors] #1723 069d56d Thanks @mcintyre94! - Add configurable instruction-count limits to transaction planners and message packers, and default planned and packed transaction messages to 16 instructions. The planner limit applies to the final transaction message, including instructions returned by createTransactionMessage or added by onTransactionMessageUpdated, and can be overridden when creating a planner or for an individual planning call.

    This is useful because Solana limits transactions to 64 instructions, including inner instructions. Kit does not know how many inner instructions each instruction will require when executed. The default of 16 assumes an average of 3 additional inner instructions per top-level instruction.

    When a transaction message reaches this configured ceiling, the planner and message packer throw the new SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED error rather than the SOLANA_ERROR__TRANSACTION__TOO_MANY_INSTRUCTIONS error reserved for the hard 64-instruction limit, so the configurable soft limit is distinguishable from the format-enforced one. Throws SOLANA_ERROR__INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION is the configured max is invalid (not a positive integer, or greater than 64).

    Configure a maximum for every plan created by a transaction planner:

    const transactionPlanner = createTransactionPlanner({
        createTransactionMessage,
        maxInstructionsPerTransaction: 32,
    });

    Override the maximum for an individual planning request:

    const transactionPlan = await transactionPlanner(instructionPlan, {
        maxInstructionsPerTransaction: 8,
    });

    Override the maximum when packing a message directly:

    const packedTransactionMessage = messagePacker.packMessageToCapacity(transactionMessage, {
        maxInstructions: 32,
    });

    BREAKING CHANGES

    Transaction planners and message packers now default to 16 instructions per transaction. Plans and direct message packer calls that previously fit 17 to 64 top-level instructions in one transaction message may now be split into multiple transaction messages. Apps that depend on larger single-transaction plans can preserve the previous top-level instruction limit by configuring maxInstructionsPerTransaction: 64 on transaction planners or maxInstructions: 64 on direct message packer calls; the hard transaction-message limit of 64 top-level instructions still applies.

     const transactionPlanner = createTransactionPlanner({
         createTransactionMessage,
    +    maxInstructionsPerTransaction: 64,
     });
    -const packedTransactionMessage = messagePacker.packMessageToCapacity(transactionMessage);
    +const packedTransactionMessage = messagePacker.packMessageToCapacity(transactionMessage, { maxInstructions: 64 });
  • [@solana/kit] #1740 a4ef3b5 Thanks @mcintyre94! - Fix createReactiveStoreWithInitialValueAndSlotTracking stranding the store in loading when a fresh connect() window is answered only by a stale-slot value

    lastUpdateSlot persists across connect() windows so the surfaced value never regresses. Previously, a successful response at a slot older than the high-water mark was dropped entirely — including the status transition — so a reconnect (e.g. a useTrackedData refresh()) answered by a lagging RPC node while a quiet account's subscription emitted nothing would sit in loading forever. A stale-slot response now settles the store back to loaded, retaining the newer data it already holds rather than regressing to the older value.

  • [@solana/react] #1719 3014977 Thanks @mcintyre94! - Add the SOLANA_ERROR__REACT__SUBSCRIPTION_CLOSED_WITHOUT_ERROR error code. useSubscriptionSWR now surfaces this SolanaError when the underlying store reaches an error state without an error value (e.g. a DataPublisher emitting undefined on its error channel, or controller.abort(null)), instead of passing the nullish value to SWR's next — which would be treated as a success and silently wipe the cached data.

  • [@solana/react] #1706 9063658 Thanks @mcintyre94! - Migrate @solana/react to depend on @solana/kit as a peer dependency (replacing its individual workspace sub-package deps) and re-export @solana/subscribable from @solana/kit so React consumers have a single import root. @solana/promises remains as a direct dep — it's a small utility that isn't part of Kit's public surface.

    For @solana/react users:

    • @solana/kit must now be installed alongside @solana/react.
    • Apps that already use both get a single deduplicated @solana/kit instance — important for anything relying on shared types or instanceof SolanaError checks.
    • Kit can be bumped independently of React within the peer range.

    For @solana/kit users:

    • ReactiveStreamSource, ReactiveStreamStore, ReactiveActionSource, ReactiveActionStore, ReactiveState, createReactiveActionStore, createReactiveStoreFromDataPublisherFactory, DataPublisher and the rest of @solana/subscribable's surface are now reachable directly through @solana/kit.
  • [@solana/rpc-spec-types] #1766 660bd74 Thanks @mcintyre94! - Validate $n BigInt value objects in parseJsonWithBigInts before materializing them. The parser now rejects non-integer and excessively large $n values with a SolanaError (SOLANA_ERROR__MALFORMED_BIGINT_STRING).

  • [@solana/rpc-transformers] #1730 4a22021 Thanks @plutohan! - Stop downcasting bigint request params to number in the default Solana RPC request transformer

    getDefaultRequestTransformerForSolanaRpc no longer runs getBigIntDowncastRequestTransformer. The Solana RPC transport already serializes bigint values losslessly as large integer literals (via stringifyJsonWithBigInts), and Agave parses JSON integers across the full u64 range without precision loss, so the lossy bigint->number downcast was redundant. Removing it also fixes silent truncation for RPC APIs configured without an onIntegerOverflow handler. getBigIntDowncastRequestTransformer is now deprecated and slated for removal in a future major version.

Don't miss a new kit release

NewReleases is sending notifications on new releases.