diff --git a/.changeset/fiery-regions-type.md b/.changeset/fiery-regions-type.md new file mode 100644 index 000000000..d35384631 --- /dev/null +++ b/.changeset/fiery-regions-type.md @@ -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 +``` diff --git a/packages/rpc-spec/README.md b/packages/rpc-spec/README.md index 0818250b8..b62f6aecf 100644 --- a/packages/rpc-spec/README.md +++ b/packages/rpc-spec/README.md @@ -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` 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 ; if (state.status === 'running' && !state.data) return ; diff --git a/packages/rpc-spec/src/__tests__/rpc-test.ts b/packages/rpc-spec/src/__tests__/rpc-test.ts index 2af3d59cf..f65ef0b27 100644 --- a/packages/rpc-spec/src/__tests__/rpc-test.ts +++ b/packages/rpc-spec/src/__tests__/rpc-test.ts @@ -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, @@ -127,6 +142,7 @@ describe('JSON-RPC 2.0', () => { const { promise, resolve } = Promise.withResolvers(); execute.mockReturnValueOnce(promise); const store = rpc.someMethod(123).reactiveStore(); + store.dispatch(); resolve(42); await jest.runAllTimersAsync(); expect(store.getState()).toStrictEqual({ @@ -140,6 +156,7 @@ describe('JSON-RPC 2.0', () => { const { promise, reject } = Promise.withResolvers(); execute.mockReturnValueOnce(promise); const store = rpc.someMethod(123).reactiveStore(); + store.dispatch(); const error = new Error('o no'); reject(error); await jest.runAllTimersAsync(); @@ -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); @@ -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(); diff --git a/packages/rpc-spec/src/rpc.ts b/packages/rpc-spec/src/rpc.ts index 43dca4f14..7d14e4565 100644 --- a/packages/rpc-spec/src/rpc.ts +++ b/packages/rpc-spec/src/rpc.ts @@ -28,21 +28,27 @@ export type Rpc = { * {@link PendingRpcRequest | PendingRpcRequest} 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 = { /** - * 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 ; * if (state.status === 'running' && !state.data) return ; @@ -119,9 +125,7 @@ function createPendingRpcRequest { 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 { return await plan.execute({ signal: options?.abortSignal, transport });