diff --git a/.changeset/bold-drinks-strive.md b/.changeset/bold-drinks-strive.md new file mode 100644 index 000000000..5939e4f6b --- /dev/null +++ b/.changeset/bold-drinks-strive.md @@ -0,0 +1,14 @@ +--- +'@solana/rpc-spec': minor +'@solana/kit': minor +--- + +Add a `reactiveStore()` method to `PendingRpcRequest`. It fires the request on construction and synchronously returns a `ReactiveActionStore` that holds the request's `idle`/`running`/`success`/`error` lifecycle state. Compatible with `useSyncExternalStore`, Svelte stores, and other reactive primitives. Call `dispatch()` to re-fire the request (e.g. after an error), or `reset()` to abort the in-flight call and return to idle. + +```ts +const store = rpc.getAccountInfo(address).reactiveStore(); +const state = useSyncExternalStore(store.subscribe, store.getState); +if (state.status === 'error') return ; +if (state.status === 'running' && !state.data) return ; +return ; +``` diff --git a/CLAUDE.md b/CLAUDE.md index 60707d0ff..280b136cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,8 @@ Four private "impl" packages (`@solana/crypto-impl`, `@solana/text-encoding-impl - **Lint/prettier**: Also run through Jest runners (`jest-runner-eslint`, `jest-runner-prettier`). - **Commands**: `pnpm test` runs all unit tests. `pnpm lint` runs lint checks. `pnpm style:fix` auto-fixes formatting. - **`expect.assertions`**: Only use `expect.assertions(n)` in **async** tests (where you need to guarantee the expected number of assertions ran). Synchronous tests do not need it. +- **Flushing async state**: When a test needs to wait for queued microtasks or promise chains to settle, prefer `jest.useFakeTimers()` + `await jest.runAllTimersAsync()` over hand-rolled `flushMicrotasks` helpers that `await Promise.resolve()` in a loop. The loop count is fragile and breaks as soon as an extra `.then` is introduced. When enabling fake timers in a scoped `beforeEach` (i.e. not at the top of the file), pair it with an `afterEach(() => { jest.useRealTimers(); })` so subsequent describes don't inherit fake timers. +- **Placeholder mocks**: When a test mock must satisfy an interface but a particular method shouldn't be called in that test, make the stub throw/reject rather than using a bare `jest.fn()` that silently returns `undefined`. For sync methods use `jest.fn().mockImplementation(() => { throw new Error('not implemented'); })`; for async methods use `jest.fn().mockRejectedValue(new Error('not implemented'))`. An accidental call then fails the test loudly instead of producing `undefined` and a confusing downstream assertion error. ## Error System diff --git a/packages/accounts/src/__tests__/__setup__.ts b/packages/accounts/src/__tests__/__setup__.ts index 3fa3a61ec..872376500 100644 --- a/packages/accounts/src/__tests__/__setup__.ts +++ b/packages/accounts/src/__tests__/__setup__.ts @@ -20,7 +20,10 @@ export function getMockRpc( ): Rpc & { getAccountInfo: jest.Mock; getMultipleAccounts: jest.Mock } { const wrapInPendingResponse = (value: T): PendingRpcRequest> => { const send = jest.fn().mockResolvedValue({ context: { slot: 0n }, value }); - return { send }; + const reactiveStore = jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }); + return { reactiveStore, send }; }; const getAccountInfo = jest diff --git a/packages/kit/src/__tests__/create-async-generator-with-initial-value-and-slot-tracking-test.ts b/packages/kit/src/__tests__/create-async-generator-with-initial-value-and-slot-tracking-test.ts index 2cd645794..e03098da0 100644 --- a/packages/kit/src/__tests__/create-async-generator-with-initial-value-and-slot-tracking-test.ts +++ b/packages/kit/src/__tests__/create-async-generator-with-initial-value-and-slot-tracking-test.ts @@ -13,7 +13,12 @@ function createMockRpcRequest(): { } { const { promise, resolve, reject } = Promise.withResolvers>(); return { - mockRequest: { send: jest.fn().mockReturnValue(promise) }, + mockRequest: { + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), + send: jest.fn().mockReturnValue(promise), + }, reject, resolve, }; diff --git a/packages/kit/src/__tests__/create-reactive-store-with-initial-value-and-slot-tracking-test.ts b/packages/kit/src/__tests__/create-reactive-store-with-initial-value-and-slot-tracking-test.ts index 982f9a86a..29fcd8656 100644 --- a/packages/kit/src/__tests__/create-reactive-store-with-initial-value-and-slot-tracking-test.ts +++ b/packages/kit/src/__tests__/create-reactive-store-with-initial-value-and-slot-tracking-test.ts @@ -13,7 +13,12 @@ function createMockRpcRequest(): { } { const { promise, resolve, reject } = Promise.withResolvers>(); return { - mockRequest: { send: jest.fn().mockReturnValue(promise) }, + mockRequest: { + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), + send: jest.fn().mockReturnValue(promise), + }, reject, resolve, }; @@ -628,6 +633,9 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { pushNotification(notification: SolanaRpcResponse): void; }[] = []; const rpcRequest: PendingRpcRequest> = { + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), send: jest.fn().mockImplementation(() => { const { promise, resolve, reject } = Promise.withResolvers>(); rpcInstances.push({ reject, resolve }); diff --git a/packages/rpc-graphql/src/loaders/__tests__/account-loader-test.ts b/packages/rpc-graphql/src/loaders/__tests__/account-loader-test.ts index 850148717..9934f1ae0 100644 --- a/packages/rpc-graphql/src/loaders/__tests__/account-loader-test.ts +++ b/packages/rpc-graphql/src/loaders/__tests__/account-loader-test.ts @@ -419,6 +419,9 @@ describe('account loader', () => { // First we should see `getMultipleAccounts` used for the first two layers rpc.getMultipleAccounts .mockImplementationOnce(() => ({ + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), send: () => Promise.resolve( getMultipleAccountsMockResponse([ @@ -442,6 +445,9 @@ describe('account loader', () => { ), })) .mockImplementationOnce(() => ({ + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), send: () => Promise.resolve( getMultipleAccountsMockResponse([ @@ -464,6 +470,9 @@ describe('account loader', () => { // Then we should see `getAccountInfo` used for the single // account in the last layer rpc.getAccountInfo.mockReturnValue({ + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), send: jest.fn().mockResolvedValueOnce({ context: { slot: 0, diff --git a/packages/rpc-spec/README.md b/packages/rpc-spec/README.md index c690f2694..0818250b8 100644 --- a/packages/rpc-spec/README.md +++ b/packages/rpc-spec/README.md @@ -34,6 +34,16 @@ 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()`. + +```ts +const store = rpc.getAccountInfo(address).reactiveStore(); +const state = useSyncExternalStore(store.subscribe, store.getState); +if (state.status === 'error') return ; +if (state.status === 'running' && !state.data) return ; +return ; +``` + ### `Rpc` An object that exposes all of the functions described by `TRpcMethods`. Calling each method returns a `PendingRpcRequest` where `TResponse` is that method's response type. diff --git a/packages/rpc-spec/package.json b/packages/rpc-spec/package.json index 90b0d375d..79ede825f 100644 --- a/packages/rpc-spec/package.json +++ b/packages/rpc-spec/package.json @@ -75,7 +75,8 @@ ], "dependencies": { "@solana/errors": "workspace:*", - "@solana/rpc-spec-types": "workspace:*" + "@solana/rpc-spec-types": "workspace:*", + "@solana/subscribable": "workspace:*" }, "peerDependencies": { "typescript": ">=5.4.0" diff --git a/packages/rpc-spec/src/__tests__/rpc-test.ts b/packages/rpc-spec/src/__tests__/rpc-test.ts index f5a28fb14..2af3d59cf 100644 --- a/packages/rpc-spec/src/__tests__/rpc-test.ts +++ b/packages/rpc-spec/src/__tests__/rpc-test.ts @@ -80,6 +80,114 @@ describe('JSON-RPC 2.0', () => { expect(rpc).not.toHaveProperty('then'); }); }); + describe('when calling reactiveStore() on a pending request', () => { + let execute: jest.Mock; + let rpc: Rpc; + beforeEach(() => { + jest.useFakeTimers(); + execute = jest.fn( + () => + new Promise(() => { + /* never resolve */ + }), + ); + rpc = createRpc({ + api: new Proxy({} as RpcApi, { + get() { + return (..._params: unknown[]): RpcPlan => ({ execute }); + }, + }), + transport: makeHttpRequest, + }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it('fires the request on creation with a non-aborted signal', () => { + rpc.someMethod(123).reactiveStore(); + 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(); + expect(execute).toHaveBeenCalledWith(expect.objectContaining({ transport: makeHttpRequest })); + }); + it('returns a store synchronously in the `running` status', () => { + const store = rpc.someMethod(123).reactiveStore(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'running', + }); + }); + it('transitions to `success` with resolved data once the plan resolves', async () => { + expect.assertions(1); + const { promise, resolve } = Promise.withResolvers(); + execute.mockReturnValueOnce(promise); + const store = rpc.someMethod(123).reactiveStore(); + resolve(42); + await jest.runAllTimersAsync(); + expect(store.getState()).toStrictEqual({ + data: 42, + error: undefined, + status: 'success', + }); + }); + it('transitions to `error` when the plan rejects', async () => { + expect.assertions(1); + const { promise, reject } = Promise.withResolvers(); + execute.mockReturnValueOnce(promise); + const store = rpc.someMethod(123).reactiveStore(); + const error = new Error('o no'); + reject(error); + await jest.runAllTimersAsync(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error, + status: 'error', + }); + }); + it('notifies subscribers when state changes', async () => { + expect.assertions(2); + const { promise, resolve } = Promise.withResolvers(); + execute.mockReturnValueOnce(promise); + const store = rpc.someMethod(123).reactiveStore(); + const subscriberA = jest.fn(); + const subscriberB = jest.fn(); + store.subscribe(subscriberA); + store.subscribe(subscriberB); + resolve(42); + await jest.runAllTimersAsync(); + expect(subscriberA).toHaveBeenCalledTimes(1); + expect(subscriberB).toHaveBeenCalledTimes(1); + }); + it('re-fires the plan when dispatch() is called', async () => { + expect.assertions(1); + // request 1: rejects + execute.mockRejectedValueOnce(new Error('o no')); + const store = rpc.someMethod(123).reactiveStore(); + await jest.runAllTimersAsync(); + // request 2: resolves + execute.mockResolvedValueOnce(42); + store.dispatch(); + await jest.runAllTimersAsync(); + expect(execute).toHaveBeenCalledTimes(2); + }); + it('aborts the in-flight signal and returns to idle when reset() is called', () => { + const store = rpc.someMethod(123).reactiveStore(); + const { signal } = execute.mock.calls[0][0]; + expect(signal.aborted).toBe(false); + store.reset(); + expect(signal.aborted).toBe(true); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + }); + }); describe('when calling a method having a concrete implementation', () => { let rpc: Rpc; beforeEach(() => { diff --git a/packages/rpc-spec/src/rpc.ts b/packages/rpc-spec/src/rpc.ts index 2b5bedeca..43dca4f14 100644 --- a/packages/rpc-spec/src/rpc.ts +++ b/packages/rpc-spec/src/rpc.ts @@ -1,5 +1,6 @@ import { SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, SolanaError } from '@solana/errors'; import { Callable, Flatten, OverloadImplementations, UnionToIntersection } from '@solana/rpc-spec-types'; +import { createReactiveActionStore, ReactiveActionStore } from '@solana/subscribable'; import { RpcApi, RpcPlan } from './rpc-api'; import { RpcTransport } from './rpc-transport'; @@ -26,8 +27,29 @@ export type Rpc = { * Calling the {@link PendingRpcRequest.send | `send(options)`} method on a * {@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. */ 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'`. + * + * @example + * ```ts + * const store = rpc.getAccountInfo(address).reactiveStore(); + * const state = useSyncExternalStore(store.subscribe, store.getState); + * if (state.status === 'error') return ; + * if (state.status === 'running' && !state.data) return ; + * return ; + * ``` + */ + reactiveStore(): ReactiveActionStore<[], TResponse>; send(options?: RpcSendOptions): Promise; }; @@ -96,6 +118,11 @@ function createPendingRpcRequest, ): PendingRpcRequest { return { + reactiveStore(): ReactiveActionStore<[], TResponse> { + const store = createReactiveActionStore<[], TResponse>(signal => plan.execute({ signal, transport })); + store.dispatch(); + return store; + }, async send(options?: RpcSendOptions): Promise { return await plan.execute({ signal: options?.abortSignal, transport }); }, diff --git a/packages/rpc-spec/tsconfig.json b/packages/rpc-spec/tsconfig.json index 117fc9eef..cd8f2c119 100644 --- a/packages/rpc-spec/tsconfig.json +++ b/packages/rpc-spec/tsconfig.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "lib": ["DOM", "ES2022.Error"] + "lib": ["DOM", "ES2022.Error", "ES2024.Promise"] }, "display": "@solana/rpc-spec", "extends": "../tsconfig/base.json", diff --git a/packages/transaction-confirmation/src/__tests__/confirmation-strategy-blockheight-test.ts b/packages/transaction-confirmation/src/__tests__/confirmation-strategy-blockheight-test.ts index 9d27e8c56..2bdc4c165 100644 --- a/packages/transaction-confirmation/src/__tests__/confirmation-strategy-blockheight-test.ts +++ b/packages/transaction-confirmation/src/__tests__/confirmation-strategy-blockheight-test.ts @@ -27,8 +27,10 @@ describe('createBlockHeightExceedencePromiseFactory', () => { }); const rpcSubscriptions = { slotNotifications: () => ({ - reactive: jest.fn(), - reactiveStore: jest.fn(), + reactive: jest.fn().mockRejectedValue(new Error('not implemented')), + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), subscribe: createSubscriptionIterable, }), }; diff --git a/packages/transaction-confirmation/src/__tests__/confirmation-strategy-nonce-test.ts b/packages/transaction-confirmation/src/__tests__/confirmation-strategy-nonce-test.ts index 5e394e783..e34c0221d 100644 --- a/packages/transaction-confirmation/src/__tests__/confirmation-strategy-nonce-test.ts +++ b/packages/transaction-confirmation/src/__tests__/confirmation-strategy-nonce-test.ts @@ -38,6 +38,9 @@ describe('createNonceInvalidationPromiseFactory', () => { getAccountInfoMock = jest.fn().mockReturnValue(FOREVER_PROMISE); const rpc = { getAccountInfo: () => ({ + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), send: getAccountInfoMock, }), }; @@ -45,8 +48,10 @@ describe('createNonceInvalidationPromiseFactory', () => { [Symbol.asyncIterator]: accountNotificationGenerator, }); createPendingSubscription = jest.fn().mockReturnValue({ - reactive: jest.fn(), - reactiveStore: jest.fn(), + reactive: jest.fn().mockRejectedValue(new Error('not implemented')), + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), subscribe: createSubscriptionIterable, }); const rpcSubscriptions = { diff --git a/packages/transaction-confirmation/src/__tests__/confirmation-strategy-signature-test.ts b/packages/transaction-confirmation/src/__tests__/confirmation-strategy-signature-test.ts index edac5a347..9cca007cd 100644 --- a/packages/transaction-confirmation/src/__tests__/confirmation-strategy-signature-test.ts +++ b/packages/transaction-confirmation/src/__tests__/confirmation-strategy-signature-test.ts @@ -21,6 +21,9 @@ describe('createSignatureConfirmationPromiseFactory', () => { getSignatureStatusesMock = jest.fn().mockReturnValue(FOREVER_PROMISE); const rpc = { getSignatureStatuses: () => ({ + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), send: getSignatureStatusesMock, }), }; @@ -28,8 +31,10 @@ describe('createSignatureConfirmationPromiseFactory', () => { [Symbol.asyncIterator]: signatureNotificationGenerator, }); createPendingSubscription = jest.fn().mockReturnValue({ - reactive: jest.fn(), - reactiveStore: jest.fn(), + reactive: jest.fn().mockRejectedValue(new Error('not implemented')), + reactiveStore: jest.fn().mockImplementation(() => { + throw new Error('not implemented'); + }), subscribe: createSubscriptionIterable, }); const rpcSubscriptions = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 562412f9e..46305eac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1156,6 +1156,9 @@ importers: '@solana/rpc-spec-types': specifier: workspace:* version: link:../rpc-spec-types + '@solana/subscribable': + specifier: workspace:* + version: link:../subscribable typescript: specifier: '>=5.4.0' version: 5.9.3