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
17 changes: 17 additions & 0 deletions .changeset/open-cameras-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@solana/subscribable': minor
'@solana/kit': minor
---

Add framework-agnostic source duck-types for reactive bindings.

`@solana/subscribable` now exports two new types:

- `ReactiveStreamSource<T>` — anything with a `reactiveStore({ abortSignal })` method that returns a `ReactiveStreamStore<T>`. `PendingRpcSubscriptionsRequest<T>` satisfies this by design.
- `ReactiveActionSource<T>` — anything with a zero-argument `reactiveStore()` method that returns a `ReactiveActionStore<[], T>`. `PendingRpcRequest<T>` satisfies this by design.

These let reactive-framework bindings consume a single duck-type instead of naming concrete producer types — and let plugin authors expose their own pending-request objects to those bindings without modification.

Both source types live in `@solana/subscribable` and are not re-exported from `@solana/kit`, matching the existing convention for their parent `ReactiveStreamStore` / `ReactiveActionStore` types — anyone consuming a source duck-type is already in the reactive-primitives layer and will already be importing the related store types from the same package.

`@solana/kit` now publicly exports the previously-private `CreateReactiveStoreWithInitialValueAndSlotTrackingConfig` type so non-React consumers (e.g. plugins) can declare function return shapes based on it without taking a dependency on `@solana/react`.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import type { PendingRpcSubscriptionsRequest } from '@solana/rpc-subscriptions';
import type { SolanaRpcResponse } from '@solana/rpc-types';
import type { ReactiveState, ReactiveStreamStore } from '@solana/subscribable';

type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TRpcValue, TSubscriptionValue, TItem> = Readonly<{
/**
* Configuration for {@link createReactiveStoreWithInitialValueAndSlotTracking}. Pairs a one-shot
* RPC fetch with an ongoing subscription so the resulting store can hydrate from the initial
* response and keep up to date with notifications, slot-deduplicating the two streams.
*
* @typeParam TRpcValue - The value type returned by `rpcRequest` (inside the {@link SolanaRpcResponse} envelope).
* @typeParam TSubscriptionValue - The value type emitted by `rpcSubscriptionRequest` (inside the {@link SolanaRpcResponse} envelope).
* @typeParam TItem - The unified item type the store holds, produced by the two value mappers.
*
* @see {@link createReactiveStoreWithInitialValueAndSlotTracking}
*/
export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TRpcValue, TSubscriptionValue, TItem> = Readonly<{
/**
* Triggering this abort signal will cancel the pending RPC request and subscription, and
* disconnect the store from further updates.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ReactiveActionSource } from '@solana/subscribable';

import { PendingRpcRequest } from '../rpc';

// `PendingRpcRequest<T>` is structurally assignable to `ReactiveActionSource<T>` — the duck-type
// reactive-framework bindings consume so they don't have to name a concrete producer type.
null as unknown as PendingRpcRequest<number> satisfies ReactiveActionSource<number>;
null as unknown as PendingRpcRequest<{ foo: string }> satisfies ReactiveActionSource<{ foo: string }>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ReactiveStreamSource } from '@solana/subscribable';

import { PendingRpcSubscriptionsRequest } from '../rpc-subscriptions-request';

// `PendingRpcSubscriptionsRequest<T>` is structurally assignable to `ReactiveStreamSource<T>` —
// the duck-type reactive-framework bindings consume so they don't have to name a concrete
// producer type.
null as unknown as PendingRpcSubscriptionsRequest<number> satisfies ReactiveStreamSource<number>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: as a stronger signal of intent, consider also asserting the inverse direction is not required — i.e. that a ReactiveStreamSource<T> is not assignable back to PendingRpcSubscriptionsRequest<T>. Something like:

// @ts-expect-error — the duck-type does not require `subscribe()` / `reactive()`.
null as unknown as ReactiveStreamSource<number> satisfies PendingRpcSubscriptionsRequest<number>;

This pins down the "strictly broader" relationship and would catch a future change that accidentally tightens ReactiveStreamSource to require methods that only the concrete producer has. Not blocking — the existing assertion already covers the contract the bindings rely on.

null as unknown as PendingRpcSubscriptionsRequest<{ foo: string }> satisfies ReactiveStreamSource<{
foo: string;
}>;
22 changes: 22 additions & 0 deletions packages/subscribable/src/reactive-action-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ export type ReactiveActionStore<TArgs extends readonly unknown[], TResult> = {
readonly subscribe: (listener: () => void) => () => void;
};

/**
* Duck-type for objects that build a {@link ReactiveActionStore} on demand via a zero-argument
* `reactiveStore()` method. Satisfied by `PendingRpcRequest<T>`. The `[]` argument tuple is
* intentional — the operation's arguments are already baked into the pending request, so each
* `dispatch()` re-fires the same call.
*
* @typeParam T - The value type resolved by the wrapped operation.
*
* @example
* ```ts
* function bind<T>(source: ReactiveActionSource<T>) {
* return source.reactiveStore();
* }
* ```
*
* @see {@link ReactiveActionStore}
* @see {@link ReactiveStreamSource}
*/
export type ReactiveActionSource<T> = {
Comment thread
mcintyre94 marked this conversation as resolved.
reactiveStore(): ReactiveActionStore<[], T>;
};

const IDLE_STATE: ReactiveActionState<never> = Object.freeze({
data: undefined,
error: undefined,
Expand Down
22 changes: 22 additions & 0 deletions packages/subscribable/src/reactive-stream-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,28 @@ export type ReactiveStreamStore<T> = {
*/
export type ReactiveStore<T> = ReactiveStreamStore<T>;

/**
* Duck-type for objects that build a {@link ReactiveStreamStore} on demand via a
* `reactiveStore({ abortSignal })` method. Satisfied by `PendingRpcSubscriptionsRequest<T>`.
* Reactive-framework bindings (e.g. React's `useSubscription`) consume this duck-type so they
* don't have to name a concrete producer type.
*
* @typeParam T - The value type emitted by the resulting stream store.
*
* @example
* ```ts
* function bind<T>(source: ReactiveStreamSource<T>, abortSignal: AbortSignal) {
* return source.reactiveStore({ abortSignal });
* }
* ```
*
* @see {@link ReactiveStreamStore}
* @see {@link ReactiveActionSource}
*/
export type ReactiveStreamSource<T> = {
reactiveStore(options: { abortSignal: AbortSignal }): ReactiveStreamStore<T>;
Comment on lines +134 to +153
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the repo's JSDoc convention (see CLAUDE.md → "TypeScript Docblocks"): exported generic types should carry @typeParam for each type parameter, and an @example where helpful. Suggest adding:

/**
 * ...existing prose...
 *
 * @typeParam T - The notification value type emitted by the produced {@link ReactiveStreamStore}.
 *
 * @example
 * ```ts
 * function bindStream<T>(source: ReactiveStreamSource<T>, abortSignal: AbortSignal) {
 *     const store = source.reactiveStore({ abortSignal });
 *     return store.subscribe(() => { /* ... *\/ });
 * }
 * ```
 */

(The promoted CreateReactiveStoreWithInitialValueAndSlotTrackingConfig in this same PR is a good model — fully tagged with @typeParam × 3.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated these

};

/**
* Returns a {@link ReactiveStreamStore} given a data publisher.
*
Expand Down
Loading