Skip to content
Open
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
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
```
3 changes: 2 additions & 1 deletion packages/rpc-spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ Pending requests are the result of calling a supported method on a `Rpc` object.

Calling the `send(options)` method on a `PendingRpcRequest<TResponse>` will trigger the request and return a promise for `TResponse`.

Calling the `reactiveStore()` method fires the request immediately and synchronously returns a [`ReactiveActionStore`](https://github.com/anza-xyz/kit/tree/main/packages/subscribable) compatible with `useSyncExternalStore`, Svelte stores, and other reactive primitives. The store starts in `status: 'running'`, transitions to `success` or `error` when the request settles, and can be re-fired via `dispatch()` or cancelled via `reset()`.
Calling the `reactiveStore()` method synchronously returns a [`ReactiveActionStore`](https://github.com/anza-xyz/kit/tree/main/packages/subscribable) in the `idle` state, compatible with `useSyncExternalStore`, Svelte stores, and other reactive primitives. The caller is responsible for firing the request via `dispatch()`; subsequent `dispatch()` calls re-fire (e.g. for retries), and `reset()` aborts the in-flight call and returns the store to `idle`.

```ts
const store = rpc.getAccountInfo(address).reactiveStore();
store.dispatch();
const state = useSyncExternalStore(store.subscribe, store.getState);
if (state.status === 'error') return <ErrorMessage error={state.error} onRetry={store.dispatch} />;
if (state.status === 'running' && !state.data) return <Spinner />;
Expand Down
35 changes: 28 additions & 7 deletions packages/rpc-spec/src/__tests__/rpc-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,34 @@ describe('JSON-RPC 2.0', () => {
afterEach(() => {
jest.useRealTimers();
});
it('fires the request on creation with a non-aborted signal', () => {
it('does not fire the request on creation', () => {
rpc.someMethod(123).reactiveStore();
expect(execute).not.toHaveBeenCalled();
});
it('returns a store in the `idle` state', () => {
const store = rpc.someMethod(123).reactiveStore();
expect(store.getState()).toStrictEqual({
data: undefined,
error: undefined,
status: 'idle',
});
});
it('fires the request on dispatch() with a non-aborted signal', () => {
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
expect(execute).toHaveBeenCalledTimes(1);
const { signal } = execute.mock.calls[0][0];
expect(signal).toBeInstanceOf(AbortSignal);
expect(signal.aborted).toBe(false);
});
it('forwards the transport to the plan on creation', () => {
rpc.someMethod(123).reactiveStore();
it('forwards the transport to the plan on dispatch()', () => {
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ transport: makeHttpRequest }));
});
it('returns a store synchronously in the `running` status', () => {
it('transitions to `running` synchronously when dispatch() is called', () => {
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
expect(store.getState()).toStrictEqual({
data: undefined,
error: undefined,
Expand All @@ -127,6 +142,7 @@ describe('JSON-RPC 2.0', () => {
const { promise, resolve } = Promise.withResolvers<number>();
execute.mockReturnValueOnce(promise);
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
resolve(42);
await jest.runAllTimersAsync();
expect(store.getState()).toStrictEqual({
Expand All @@ -140,6 +156,7 @@ describe('JSON-RPC 2.0', () => {
const { promise, reject } = Promise.withResolvers<number>();
execute.mockReturnValueOnce(promise);
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
const error = new Error('o no');
reject(error);
await jest.runAllTimersAsync();
Expand All @@ -158,16 +175,19 @@ describe('JSON-RPC 2.0', () => {
const subscriberB = jest.fn();
store.subscribe(subscriberA);
store.subscribe(subscriberB);
store.dispatch();
resolve(42);
await jest.runAllTimersAsync();
expect(subscriberA).toHaveBeenCalledTimes(1);
expect(subscriberB).toHaveBeenCalledTimes(1);
// Both subscribers see at least the running and success transitions.
expect(subscriberA.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(subscriberB.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('re-fires the plan when dispatch() is called', async () => {
it('re-fires the plan when dispatch() is called repeatedly', async () => {
expect.assertions(1);
// request 1: rejects
execute.mockRejectedValueOnce(new Error('o no'));
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
await jest.runAllTimersAsync();
// request 2: resolves
execute.mockResolvedValueOnce(42);
Expand All @@ -177,6 +197,7 @@ describe('JSON-RPC 2.0', () => {
});
it('aborts the in-flight signal and returns to idle when reset() is called', () => {
const store = rpc.someMethod(123).reactiveStore();
store.dispatch();
const { signal } = execute.mock.calls[0][0];
expect(signal.aborted).toBe(false);
store.reset();
Expand Down
26 changes: 15 additions & 11 deletions packages/rpc-spec/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,27 @@ export type Rpc<TRpcMethods> = {
* {@link PendingRpcRequest | PendingRpcRequest<TResponse>} will trigger the request and return a
* promise for `TResponse`.
*
* Calling the {@link PendingRpcRequest.reactiveStore | `reactiveStore()`} method will fire the
* request and return a {@link ReactiveActionStore} compatible with `useSyncExternalStore`, Svelte
* stores, and other reactive primitives.
* Calling the {@link PendingRpcRequest.reactiveStore | `reactiveStore()`} method will return a
* {@link ReactiveActionStore} compatible with `useSyncExternalStore`, Svelte stores, and other
* reactive primitives. The store is returned in the `idle` state — call `dispatch()` to fire the
* request.
*/
export type PendingRpcRequest<TResponse> = {
/**
* Synchronously returns a {@link ReactiveActionStore} that fires the request on construction
* and holds its lifecycle state. Compatible with `useSyncExternalStore` and other reactive
* primitives that expect a `{ subscribe, getState }` contract. Call `dispatch()` to re-fire the
* request (for example after an error), or `reset()` to abort the in-flight call and return to
* `status: 'idle'`.
* Synchronously returns a {@link ReactiveActionStore} in the `idle` state, ready to dispatch
* the underlying request. Compatible with `useSyncExternalStore` and other reactive primitives
* that expect a `{ subscribe, getState }` contract. Call `dispatch()` to fire the request,
* again on retry, or `reset()` to abort the in-flight call and return to `status: 'idle'`.
*
* Unlike {@link PendingRpcRequest.send}, this method does not fire the request on creation —
* the caller is responsible for dispatching. This makes signal handling uniform between
* `reactiveStore`-derived stores and stores created directly from `createReactiveActionStore`
* (which also do not auto-fire).
*
* @example
* ```ts
* const store = rpc.getAccountInfo(address).reactiveStore();
* store.dispatch(); // fire the initial request
* const state = useSyncExternalStore(store.subscribe, store.getState);
* if (state.status === 'error') return <ErrorMessage error={state.error} onRetry={store.dispatch} />;
* if (state.status === 'running' && !state.data) return <Spinner />;
Expand Down Expand Up @@ -119,9 +125,7 @@ function createPendingRpcRequest<TRpcMethods, TRpcTransport extends RpcTransport
): PendingRpcRequest<TResponse> {
return {
reactiveStore(): ReactiveActionStore<[], TResponse> {
const store = createReactiveActionStore<[], TResponse>(signal => plan.execute({ signal, transport }));
store.dispatch();
return store;
return createReactiveActionStore<[], TResponse>(signal => plan.execute({ signal, transport }));
},
async send(options?: RpcSendOptions): Promise<TResponse> {
return await plan.execute({ signal: options?.abortSignal, transport });
Expand Down
Loading