diff --git a/.changeset/blue-webs-work.md b/.changeset/blue-webs-work.md new file mode 100644 index 000000000..9ed0de58e --- /dev/null +++ b/.changeset/blue-webs-work.md @@ -0,0 +1,23 @@ +--- +'@solana/subscribable': major +'@solana/rpc-subscriptions-spec': major +'@solana/kit': major +--- + +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. + +```ts +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` to `(signal: AbortSignal) => Promise`. 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.reactiveStore()` is now parameter-less (mirrors `ReactiveActionSource.reactiveStore()`). diff --git a/.changeset/bumpy-seas-melt.md b/.changeset/bumpy-seas-melt.md new file mode 100644 index 000000000..79e843ce3 --- /dev/null +++ b/.changeset/bumpy-seas-melt.md @@ -0,0 +1,25 @@ +--- +'@solana/subscribable': major +'@solana/rpc-subscriptions-spec': major +'@solana/kit': major +--- + +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 `loading` → `loaded` (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. + +```ts +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. diff --git a/.changeset/cool-coats-invent.md b/.changeset/cool-coats-invent.md new file mode 100644 index 000000000..49c88a5f4 --- /dev/null +++ b/.changeset/cool-coats-invent.md @@ -0,0 +1,10 @@ +--- +'@solana/subscribable': minor +--- + +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. diff --git a/.changeset/empty-deserts-happen.md b/.changeset/empty-deserts-happen.md new file mode 100644 index 000000000..86cfbb6e1 --- /dev/null +++ b/.changeset/empty-deserts-happen.md @@ -0,0 +1,23 @@ +--- +'@solana/rpc-spec': minor +'@solana/rpc-subscriptions-spec': minor +'@solana/kit': minor +--- + +Add `RpcSendable` and `RpcSubscribable` structural duck-types alongside the concrete `PendingRpcRequest` and `PendingRpcSubscriptionsRequest`. Both new types are intentionally narrower — `RpcSendable` covers just `send({ abortSignal })` and `RpcSubscribable` covers just `subscribe({ abortSignal })` — so consumers (higher-level kit helpers, plugin-authored wrappers, test mocks) can accept request-like objects without taking on the full `{ reactiveStore, send }` / `{ reactiveStore, subscribe }` shape. + +```ts +import type { RpcSendable, RpcSubscribable } from '@solana/kit'; + +type RpcSendable = { + send(options?: RpcSendOptions): Promise; +}; + +type RpcSubscribable = { + subscribe(options: RpcSubscribeOptions): Promise>; +}; +``` + +`PendingRpcRequest` still structurally satisfies `RpcSendable`; `PendingRpcSubscriptionsRequest` still satisfies `RpcSubscribable`. No change at producer boundaries (`rpc.(...)` / `rpcSubscriptions.(...)` returns). + +Loosens the `rpcRequest` / `rpcSubscriptionRequest` fields on `CreateReactiveStoreWithInitialValueAndSlotTrackingConfig` (exported) and the internal config for `createAsyncGeneratorWithInitialValueAndSlotTracking` from `PendingRpcRequest<...>` / `PendingRpcSubscriptionsRequest<...>` to `RpcSendable<...>` / `RpcSubscribable<...>` — both primitives only ever call `.send()` and `.subscribe()` on those inputs, so they now accept any compatible duck-type. Existing callers passing concrete `Pending*Request` objects continue to work. 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/.changeset/four-pots-occur.md b/.changeset/four-pots-occur.md new file mode 100644 index 000000000..5ef699a39 --- /dev/null +++ b/.changeset/four-pots-occur.md @@ -0,0 +1,11 @@ +--- +'@solana/react': minor +--- + +Add `useAction` — a React hook that bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset` and supersede-on-second-call semantics. Built on `createReactiveActionStore` from `@solana/subscribable`. + +The wrapped function receives a fresh `AbortSignal` per `send(...)`. Calling `dispatch` again while a prior call is in flight aborts the first; awaiters of the superseded 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 `send(...)` 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` type is also exported so plugin hooks can declare their return shape against it. diff --git a/.changeset/fresh-eggs-hear.md b/.changeset/fresh-eggs-hear.md new file mode 100644 index 000000000..fc10f9275 --- /dev/null +++ b/.changeset/fresh-eggs-hear.md @@ -0,0 +1,24 @@ +--- +'@solana/react': minor +--- + +Add `useRequest` — a React hook for one-shot async reads. Pass either an async function `(signal) => Promise` or a memoized `ReactiveActionSource` (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. + +```tsx +// `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` and `UseRequestOptions` types are exported alongside the hook so plugin hooks built on top can declare their return shape against them. diff --git a/.changeset/humble-coins-speak.md b/.changeset/humble-coins-speak.md new file mode 100644 index 000000000..55a6f8d19 --- /dev/null +++ b/.changeset/humble-coins-speak.md @@ -0,0 +1,14 @@ +--- +'@solana/react': minor +--- + +Add `useSubscriptionSwr(key, source, options?)` to the `@solana/react/swr` subpath — the SWR-backed counterpart to `useSubscription`. Routes a `ReactiveStreamSource` through SWR's subscription cache (`useSWRSubscription`). + +```tsx +import { useSubscriptionSwr } from '@solana/react/swr'; + +const { data } = useSubscriptionSwr(['account', address], client.rpcSubscriptions.accountNotifications(address)); +// For envelope sources: data is `SolanaRpcResponse<{ lamports: bigint }> | undefined` +``` + +`data` is the notification exactly as the source emits it — no unwrapping. For RPC subscriptions that emit `SolanaRpcResponse` envelopes, 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. Mirrors core `useSubscription`'s result so moving between the two is a pure swap. Pass `null` for either `key` or `source` to disable. Options accept SWR's config plus `getAbortSignal` for per-connection signals. diff --git a/.changeset/icy-loops-show.md b/.changeset/icy-loops-show.md new file mode 100644 index 000000000..bbd4af1f0 --- /dev/null +++ b/.changeset/icy-loops-show.md @@ -0,0 +1,14 @@ +--- +'@solana/react': minor +'@solana/errors': minor +--- + +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 `` 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()` 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({ 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. diff --git a/.changeset/icy-sites-clap.md b/.changeset/icy-sites-clap.md new file mode 100644 index 000000000..492b14132 --- /dev/null +++ b/.changeset/icy-sites-clap.md @@ -0,0 +1,23 @@ +--- +'@solana/react': minor +--- + +Add `useSubscription` — a React hook for subscription-based live data. Pass a `ReactiveStreamSource` (satisfied by `PendingRpcSubscriptionsRequest`) and the hook opens the subscription on mount, re-opens whenever the source identity changes, and tears it down on unmount. + +```tsx +function AccountBalance({ address }: { address: Address }) { + const client = useClient>(); + const source = useMemo(() => client.rpcSubscriptions.accountNotifications(address), [client, address]); + const { data, error, reconnect } = useSubscription(source); + if (error) return ; + return

{data ? `${data.value.lamports} lamports at slot ${data.context.slot}` : 'Connecting…'}

; +} +``` + +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` (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` and `UseSubscriptionOptions` are exported alongside the hook so plugin hooks built on top can declare their return shape against them. diff --git a/.changeset/mighty-clouds-admire.md b/.changeset/mighty-clouds-admire.md new file mode 100644 index 000000000..e7cdb942f --- /dev/null +++ b/.changeset/mighty-clouds-admire.md @@ -0,0 +1,13 @@ +--- +'@solana/react': minor +--- + +Add `@solana/react/swr` subpath with `useRequestSwr(key, source, options?)` — the SWR-backed counterpart to `useRequest`. Same source shape (`ReactiveActionSource` or `(signal) => Promise`); returns SWR's native `SWRResponse`. Pass `null` for either `key` or `source` to disable. Requires `swr@^2` as an optional peer dependency. + +```tsx +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. diff --git a/.changeset/moody-heads-design.md b/.changeset/moody-heads-design.md new file mode 100644 index 000000000..5e6751e87 --- /dev/null +++ b/.changeset/moody-heads-design.md @@ -0,0 +1,31 @@ +--- +'@solana/react': minor +--- + +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` 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). + +```tsx +function AccountBalance({ address }: { address: Address }) { + const client = useClient & ClientWithRpcSubscriptions>(); + 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 ; + return

{data ? `${data.value} lamports at slot ${data.context.slot}` : 'Loading…'}

; +} +``` + +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` 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`, `TrackedDataSpec`, and `UseTrackedDataOptions` are exported alongside the hook for plugin hooks built on top. diff --git a/.changeset/odd-bobcats-see.md b/.changeset/odd-bobcats-see.md new file mode 100644 index 000000000..c6e4eadf7 --- /dev/null +++ b/.changeset/odd-bobcats-see.md @@ -0,0 +1,22 @@ +--- +'@solana/subscribable': major +'@solana/kit': major +--- + +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` 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. + +```ts +// 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). diff --git a/.changeset/purple-readers-obey.md b/.changeset/purple-readers-obey.md new file mode 100644 index 000000000..1969aaef0 --- /dev/null +++ b/.changeset/purple-readers-obey.md @@ -0,0 +1,14 @@ +--- +'@solana/react': patch +'@solana/kit': minor +--- + +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`. diff --git a/.changeset/thirty-parrots-invite.md b/.changeset/thirty-parrots-invite.md new file mode 100644 index 000000000..8b3d86589 --- /dev/null +++ b/.changeset/thirty-parrots-invite.md @@ -0,0 +1,6 @@ +--- +'@solana/subscribable': minor +'@solana/react': minor +--- + +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 `send()` until the new call resolves. diff --git a/.changeset/true-geese-bow.md b/.changeset/true-geese-bow.md new file mode 100644 index 000000000..050b4c328 --- /dev/null +++ b/.changeset/true-geese-bow.md @@ -0,0 +1,24 @@ +--- +'@solana/rpc-types': minor +--- + +Add `UnwrapRpcResponse` 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` is a conditional type: + +```ts +type UnwrapRpcResponse = T extends SolanaRpcResponse ? 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>`, so callers don't need to spell out the inner type separately. + +```ts +import { isSolanaRpcResponse } from '@solana/rpc-types'; + +function lift(notification: T) { + if (isSolanaRpcResponse(notification)) { + return { slot: notification.context.slot, value: notification.value }; + } + return { slot: undefined, value: notification }; +} +``` diff --git a/CLAUDE.md b/CLAUDE.md index 61301dbbd..27147dda6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ Four private "impl" packages (`@solana/crypto-impl`, `@solana/text-encoding-impl - **`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. +- **React hook tests**: Use `renderHook` (and `render`) from `packages/react/src/__test-utils__/render.tsx` — these wrap every tree in ``. Do NOT import `renderHook` / `render` directly from `@testing-library/react` for new tests in the `@solana/react` package. StrictMode's dev double-render surfaces render-phase impurity (side effects in `useMemo` / state initializers, missing effect cleanups, refs read during render) that would otherwise only manifest in real apps. When effect setups legitimately double under StrictMode, assert on end-state (signal aborted, store reset to idle) rather than raw call counts. ## Error System diff --git a/packages/build-scripts/getBaseConfig.ts b/packages/build-scripts/getBaseConfig.ts index 80287f78e..50ad0717d 100644 --- a/packages/build-scripts/getBaseConfig.ts +++ b/packages/build-scripts/getBaseConfig.ts @@ -14,11 +14,15 @@ type Platform = const BROWSERSLIST_TARGETS = browsersListToEsBuild(); export function getBaseConfig(platform: Platform, formats: Format[], _options: Options): Options[] { - // `@solana/kit` has an additional subpath entry point that should be published separately. + // Packages with additional subpath entry points list them here so each subpath gets its own + // platform-specific bundle (`dist/..`). Consumers import the subpath + // via the package.json `exports` map. const moduleEntry = env.npm_package_name === '@solana/kit' ? ['./src/index.ts', './src/program-client-core.ts'] - : ['./src/index.ts']; + : env.npm_package_name === '@solana/react' + ? ['./src/index.ts', './src/swr.ts'] + : ['./src/index.ts']; return [true, false] .flatMap(isDebugBuild => diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index c7aa654b5..1eef289e3 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -396,6 +396,11 @@ export const SOLANA_ERROR__WALLET__NOT_CONNECTED = 8900000; export const SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED = 8900001; export const SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE = 8900002; +// React-binding errors. +// Reserve error codes in the range [9000000-9000999]. +export const SOLANA_ERROR__REACT__MISSING_PROVIDER = 9000000; +export const SOLANA_ERROR__REACT__MISSING_CAPABILITY = 9000001; + // Invariant violation errors. // Reserve error codes in the range [9900000-9900999]. // These errors should only be thrown when there is a bug with the @@ -627,6 +632,8 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE | typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE | typeof SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE + | typeof SOLANA_ERROR__REACT__MISSING_CAPABILITY + | typeof SOLANA_ERROR__REACT__MISSING_PROVIDER | typeof SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD | typeof SOLANA_ERROR__RPC__INTEGER_OVERFLOW | typeof SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 5e9275d22..274c9eb91 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -178,6 +178,8 @@ import { SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE, + SOLANA_ERROR__REACT__MISSING_CAPABILITY, + SOLANA_ERROR__REACT__MISSING_PROVIDER, SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, SOLANA_ERROR__RPC__INTEGER_OVERFLOW, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, @@ -788,6 +790,14 @@ export type SolanaErrorContext = ReadonlyContextValue< instructionType: number | string; programName: string; }; + [SOLANA_ERROR__REACT__MISSING_CAPABILITY]: { + capabilities: readonly string[]; + hookName: string; + providerHint: string; + }; + [SOLANA_ERROR__REACT__MISSING_PROVIDER]: { + hookName: string; + }; [SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_PLAN]: { notificationName: string; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index c90845dae..0056b4a85 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -209,6 +209,8 @@ import { SOLANA_ERROR__PROGRAM_CLIENTS__UNEXPECTED_RESOLVED_INSTRUCTION_INPUT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_ACCOUNT_TYPE, SOLANA_ERROR__PROGRAM_CLIENTS__UNRECOGNIZED_INSTRUCTION_TYPE, + SOLANA_ERROR__REACT__MISSING_CAPABILITY, + SOLANA_ERROR__REACT__MISSING_PROVIDER, SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, SOLANA_ERROR__RPC__INTEGER_OVERFLOW, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, @@ -870,6 +872,10 @@ export const SolanaErrorMessages: Readonly<{ 'Transaction has $actualCount instructions but the maximum allowed is $maxAllowed', [SOLANA_ERROR__TRANSACTION__TOO_MANY_ACCOUNTS_IN_INSTRUCTION]: 'The instruction at index $instructionIndex has $actualCount account references but the maximum allowed is $maxAllowed', + [SOLANA_ERROR__REACT__MISSING_CAPABILITY]: + '`$hookName` requires the following capabilities to be installed on the client: [$capabilities]. $providerHint', + [SOLANA_ERROR__REACT__MISSING_PROVIDER]: + '`$hookName` was called outside of a `ClientProvider`. Mount a `` in the ancestor tree.', [SOLANA_ERROR__WALLET__NOT_CONNECTED]: 'Cannot $operation: no wallet connected', [SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED]: 'No signing wallet connected (status: $status)', [SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE]: 'Connected wallet does not support signing', 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 e03098da0..27a269781 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 @@ -1,5 +1,5 @@ -import type { PendingRpcRequest } from '@solana/rpc'; -import type { PendingRpcSubscriptionsRequest } from '@solana/rpc-subscriptions'; +import type { RpcSendable } from '@solana/rpc'; +import type { RpcSubscribable } from '@solana/rpc-subscriptions'; import type { SolanaRpcResponse } from '@solana/rpc-types'; import { createAsyncGeneratorWithInitialValueAndSlotTracking } from '../create-async-generator-with-initial-value-and-slot-tracking'; @@ -7,16 +7,13 @@ import { createAsyncGeneratorWithInitialValueAndSlotTracking } from '../create-a type TestValue = { count: number }; function createMockRpcRequest(): { - mockRequest: PendingRpcRequest>; + mockRequest: RpcSendable>; reject(error: unknown): void; resolve(response: SolanaRpcResponse): void; } { const { promise, resolve, reject } = Promise.withResolvers>(); return { mockRequest: { - reactiveStore: jest.fn().mockImplementation(() => { - throw new Error('not implemented'); - }), send: jest.fn().mockReturnValue(promise), }, reject, @@ -27,7 +24,7 @@ function createMockRpcRequest(): { function createMockSubscriptionRequest(): { complete(): void; error(err: unknown): void; - mockRequest: PendingRpcSubscriptionsRequest>; + mockRequest: RpcSubscribable>; pushNotification(notification: SolanaRpcResponse): void; } { const notifications: SolanaRpcResponse[] = []; @@ -93,10 +90,6 @@ function createMockSubscriptionRequest(): { complete, error, mockRequest: { - reactive: jest.fn().mockRejectedValue(new Error('not implemented')), - reactiveStore: jest.fn().mockImplementation(() => { - throw new Error('not implemented'); - }), subscribe: jest.fn().mockResolvedValue(asyncIterable), }, pushNotification, 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 29fcd8656..80a54d0ea 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 @@ -1,5 +1,5 @@ -import type { PendingRpcRequest } from '@solana/rpc'; -import type { PendingRpcSubscriptionsRequest } from '@solana/rpc-subscriptions'; +import type { RpcSendable } from '@solana/rpc'; +import type { RpcSubscribable } from '@solana/rpc-subscriptions'; import type { SolanaRpcResponse } from '@solana/rpc-types'; import { createReactiveStoreWithInitialValueAndSlotTracking } from '../create-reactive-store-with-initial-value-and-slot-tracking'; @@ -7,16 +7,13 @@ import { createReactiveStoreWithInitialValueAndSlotTracking } from '../create-re type TestValue = { count: number }; function createMockRpcRequest(): { - mockRequest: PendingRpcRequest>; + mockRequest: RpcSendable>; reject(error: unknown): void; resolve(response: SolanaRpcResponse): void; } { const { promise, resolve, reject } = Promise.withResolvers>(); return { mockRequest: { - reactiveStore: jest.fn().mockImplementation(() => { - throw new Error('not implemented'); - }), send: jest.fn().mockReturnValue(promise), }, reject, @@ -27,7 +24,7 @@ function createMockRpcRequest(): { function createMockSubscriptionRequest(): { complete(): void; error(err: unknown): void; - mockRequest: PendingRpcSubscriptionsRequest>; + mockRequest: RpcSubscribable>; pushNotification(notification: SolanaRpcResponse): void; } { const notifications: SolanaRpcResponse[] = []; @@ -93,10 +90,6 @@ function createMockSubscriptionRequest(): { complete, error, mockRequest: { - reactive: jest.fn().mockRejectedValue(new Error('not implemented')), - reactiveStore: jest.fn().mockImplementation(() => { - throw new Error('not implemented'); - }), subscribe: jest.fn().mockResolvedValue(asyncIterable), }, pushNotification, @@ -125,12 +118,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); expect(store.getState()).toBeUndefined(); }); it('updates with the RPC response value', async () => { @@ -138,12 +131,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); resolve(rpcResponse(100, { count: 42 })); await jest.runAllTimersAsync(); expect(store.getState()).toEqual({ context: { slot: 100n }, value: 42 }); @@ -153,12 +146,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, pushNotification } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); await jest.runAllTimersAsync(); pushNotification(rpcResponse(100, { count: 99 })); await jest.runAllTimersAsync(); @@ -169,12 +162,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, pushNotification } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); await jest.runAllTimersAsync(); pushNotification(rpcResponse(200, { count: 99 })); await jest.runAllTimersAsync(); @@ -188,12 +181,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, pushNotification } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); resolve(rpcResponse(200, { count: 42 })); await jest.runAllTimersAsync(); pushNotification(rpcResponse(100, { count: 99 })); @@ -205,12 +198,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, error } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); resolve(rpcResponse(100, { count: 42 })); await jest.runAllTimersAsync(); error(new Error('subscription failed')); @@ -224,12 +217,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); expect(store.getError()).toBeUndefined(); }); it('captures an error from the RPC request', async () => { @@ -237,12 +230,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, reject } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const error = new Error('rpc failed'); reject(error); await jest.runAllTimersAsync(); @@ -253,12 +246,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, error } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); await jest.runAllTimersAsync(); const subscriptionError = new Error('subscription failed'); error(subscriptionError); @@ -270,12 +263,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, reject: rejectRpc } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, error: errorSubscription } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); await jest.runAllTimersAsync(); rejectRpc(new Error('rpc error')); await jest.runAllTimersAsync(); @@ -288,12 +281,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, reject: rejectRpc } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, error: errorSubscription } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); await jest.runAllTimersAsync(); errorSubscription(new Error('subscription error')); await jest.runAllTimersAsync(); @@ -309,12 +302,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const subscriber = jest.fn(); store.subscribe(subscriber); resolve(rpcResponse(100, { count: 42 })); @@ -326,12 +319,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, pushNotification } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const subscriber = jest.fn(); store.subscribe(subscriber); await jest.runAllTimersAsync(); @@ -344,12 +337,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, pushNotification } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const subscriber = jest.fn(); store.subscribe(subscriber); resolve(rpcResponse(200, { count: 42 })); @@ -366,12 +359,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, reject } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const subscriber = jest.fn(); store.subscribe(subscriber); reject(new Error('fail')); @@ -383,12 +376,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, error } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const subscriber = jest.fn(); store.subscribe(subscriber); await jest.runAllTimersAsync(); @@ -401,12 +394,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const subscriber = jest.fn(); const unsubscribe = store.subscribe(subscriber); unsubscribe(); @@ -418,12 +411,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const unsubscribe = store.subscribe(jest.fn()); expect(() => { unsubscribe(); @@ -432,110 +425,106 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { }); }); - describe('abort signal', () => { - it('aborts the signal passed to the RPC request when the caller aborts', () => { + describe('withSignal()', () => { + it('forwards the composed signal to the RPC request', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); - createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, + const store = createReactiveStoreWithInitialValueAndSlotTracking({ rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.withSignal(abortController.signal).connect(); const rpcSignal = (rpcRequest.send as jest.Mock).mock.calls[0][0].abortSignal; expect(rpcSignal.aborted).toBe(false); abortController.abort('test reason'); expect(rpcSignal.aborted).toBe(true); expect(rpcSignal.reason).toBe('test reason'); }); - it('aborts the signal passed to the subscription request when the caller aborts', () => { + it('forwards the composed signal to the subscription request', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); - createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, + const store = createReactiveStoreWithInitialValueAndSlotTracking({ rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.withSignal(abortController.signal).connect(); const subscriptionSignal = (rpcSubscriptionRequest.subscribe as jest.Mock).mock.calls[0][0].abortSignal; expect(subscriptionSignal.aborted).toBe(false); abortController.abort('test reason'); expect(subscriptionSignal.aborted).toBe(true); expect(subscriptionSignal.reason).toBe('test reason'); }); - it('swallows errors from the RPC request when the caller aborts', async () => { - expect.assertions(1); - const { mockRequest: rpcRequest, reject } = createMockRpcRequest(); + it('transitions to `error` with the caller signal abort reason', () => { + const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); - abortController.abort(); - reject(new Error('aborted')); - await jest.runAllTimersAsync(); - expect(store.getError()).toBeUndefined(); + store.withSignal(abortController.signal).connect(); + const reason = new Error('timed out'); + abortController.abort(reason); + expect(store.getUnifiedState()).toStrictEqual({ + data: undefined, + error: reason, + status: 'error', + }); }); - it('swallows errors from the subscription when the caller aborts', async () => { + it('does not overwrite the abort-reason error with a late RPC rejection', async () => { expect.assertions(1); - const { mockRequest: rpcRequest } = createMockRpcRequest(); - const { mockRequest: rpcSubscriptionRequest, error } = createMockSubscriptionRequest(); + const { mockRequest: rpcRequest, reject } = createMockRpcRequest(); + const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.withSignal(abortController.signal).connect(); + const reason = new Error('cancelled'); + abortController.abort(reason); + reject(new Error('rpc-late')); await jest.runAllTimersAsync(); - abortController.abort(); - error(new Error('aborted')); - await jest.runAllTimersAsync(); - expect(store.getError()).toBeUndefined(); + expect(store.getError()).toBe(reason); }); it('does not update state when the RPC response arrives after abort', async () => { - expect.assertions(2); + expect.assertions(1); const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); - const subscriber = jest.fn(); - store.subscribe(subscriber); + store.withSignal(abortController.signal).connect(); abortController.abort(); resolve(rpcResponse(100, { count: 42 })); await jest.runAllTimersAsync(); expect(store.getState()).toBeUndefined(); - expect(subscriber).not.toHaveBeenCalled(); }); it('does not update state when a subscription notification arrives after abort', async () => { - expect.assertions(2); + expect.assertions(1); const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, pushNotification } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); - const subscriber = jest.fn(); - store.subscribe(subscriber); + store.withSignal(abortController.signal).connect(); await jest.runAllTimersAsync(); abortController.abort(); pushNotification(rpcResponse(100, { count: 99 })); await jest.runAllTimersAsync(); expect(store.getState()).toBeUndefined(); - expect(subscriber).not.toHaveBeenCalled(); }); }); @@ -544,12 +533,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); expect(store.getUnifiedState()).toStrictEqual({ data: undefined, error: undefined, @@ -561,12 +550,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); resolve(rpcResponse(100, { count: 42 })); await jest.runAllTimersAsync(); expect(store.getUnifiedState()).toStrictEqual({ @@ -580,12 +569,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, reject } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); const failure = new Error('rpc failed'); reject(failure); await jest.runAllTimersAsync(); @@ -600,12 +589,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { const { mockRequest: rpcRequest, resolve } = createMockRpcRequest(); const { mockRequest: rpcSubscriptionRequest, error } = createMockSubscriptionRequest(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); resolve(rpcResponse(100, { count: 42 })); await jest.runAllTimersAsync(); const failure = new Error('subscription failed'); @@ -632,21 +621,14 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { error(err: unknown): void; pushNotification(notification: SolanaRpcResponse): void; }[] = []; - const rpcRequest: PendingRpcRequest> = { - reactiveStore: jest.fn().mockImplementation(() => { - throw new Error('not implemented'); - }), + const rpcRequest: RpcSendable> = { send: jest.fn().mockImplementation(() => { const { promise, resolve, reject } = Promise.withResolvers>(); rpcInstances.push({ reject, resolve }); return promise; }), }; - const rpcSubscriptionRequest: PendingRpcSubscriptionsRequest> = { - reactive: jest.fn().mockRejectedValue(new Error('not implemented')), - reactiveStore: jest.fn().mockImplementation(() => { - throw new Error('not implemented'); - }), + const rpcSubscriptionRequest: RpcSubscribable> = { subscribe: jest.fn().mockImplementation(() => { const instance = createMockSubscriptionRequest(); subscriptionInstances.push({ @@ -663,47 +645,48 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { expect.assertions(1); const { rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); await jest.runAllTimersAsync(); store.retry(); expect(rpcRequest.send).toHaveBeenCalledTimes(1); }); - it('transitions to `retrying` with preserved data and clears the error', async () => { + it('transitions back to `loading` with preserved data AND error (SWR)', async () => { expect.assertions(1); const { rpcInstances, rpcRequest, rpcSubscriptionRequest, subscriptionInstances } = createRetryableMocks(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); rpcInstances[0].resolve(rpcResponse(100, { count: 42 })); await jest.runAllTimersAsync(); - subscriptionInstances[0].error(new Error('stream died')); + const fail = new Error('stream died'); + subscriptionInstances[0].error(fail); await jest.runAllTimersAsync(); store.retry(); expect(store.getUnifiedState()).toStrictEqual({ data: { context: { slot: 100n }, value: 42 }, - error: undefined, - status: 'retrying', + error: fail, + status: 'loading', }); }); it('re-invokes the RPC request and subscription on retry', async () => { expect.assertions(2); const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); rpcInstances[0].reject(new Error('boom')); await jest.runAllTimersAsync(); store.retry(); @@ -715,12 +698,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { expect.assertions(1); const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); rpcInstances[0].reject(new Error('first failure')); await jest.runAllTimersAsync(); store.retry(); @@ -737,12 +720,12 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { expect.assertions(1); const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); rpcInstances[0].reject(new Error('first')); await jest.runAllTimersAsync(); store.retry(); @@ -756,16 +739,16 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { status: 'error', }); }); - it('notifies subscribers on the retrying transition', async () => { + it('notifies subscribers on the error → loading transition after retry', async () => { expect.assertions(1); const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, rpcRequest, rpcSubscriptionRequest, rpcSubscriptionValueMapper: v => v.count, rpcValueMapper: v => v.count, }); + store.connect(); rpcInstances[0].reject(new Error('fail')); await jest.runAllTimersAsync(); const subscriber = jest.fn(); @@ -773,63 +756,5 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => { store.retry(); expect(subscriber).toHaveBeenCalledTimes(1); }); - it('does not re-invoke the RPC request after the caller has aborted', async () => { - expect.assertions(1); - const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); - const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, - rpcRequest, - rpcSubscriptionRequest, - rpcSubscriptionValueMapper: v => v.count, - rpcValueMapper: v => v.count, - }); - rpcInstances[0].reject(new Error('fail')); - await jest.runAllTimersAsync(); - abortController.abort(); - store.retry(); - await jest.runAllTimersAsync(); - expect(rpcRequest.send).toHaveBeenCalledTimes(1); - }); - it('leaves the store in `error` state after the caller has aborted', async () => { - expect.assertions(1); - const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); - const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, - rpcRequest, - rpcSubscriptionRequest, - rpcSubscriptionValueMapper: v => v.count, - rpcValueMapper: v => v.count, - }); - const failure = new Error('fail'); - rpcInstances[0].reject(failure); - await jest.runAllTimersAsync(); - abortController.abort(); - store.retry(); - await jest.runAllTimersAsync(); - expect(store.getUnifiedState()).toStrictEqual({ - data: undefined, - error: failure, - status: 'error', - }); - }); - it('does not notify subscribers after the caller has aborted', async () => { - expect.assertions(1); - const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks(); - const store = createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal: abortController.signal, - rpcRequest, - rpcSubscriptionRequest, - rpcSubscriptionValueMapper: v => v.count, - rpcValueMapper: v => v.count, - }); - rpcInstances[0].reject(new Error('fail')); - await jest.runAllTimersAsync(); - abortController.abort(); - const subscriber = jest.fn(); - store.subscribe(subscriber); - store.retry(); - await jest.runAllTimersAsync(); - expect(subscriber).not.toHaveBeenCalled(); - }); }); }); diff --git a/packages/kit/src/create-async-generator-with-initial-value-and-slot-tracking.ts b/packages/kit/src/create-async-generator-with-initial-value-and-slot-tracking.ts index 7b92ac987..ea5ab9b59 100644 --- a/packages/kit/src/create-async-generator-with-initial-value-and-slot-tracking.ts +++ b/packages/kit/src/create-async-generator-with-initial-value-and-slot-tracking.ts @@ -1,5 +1,5 @@ -import type { PendingRpcRequest } from '@solana/rpc'; -import type { PendingRpcSubscriptionsRequest } from '@solana/rpc-subscriptions'; +import type { RpcSendable } from '@solana/rpc'; +import type { RpcSubscribable } from '@solana/rpc-subscriptions'; import type { SolanaRpcResponse } from '@solana/rpc-types'; type CreateAsyncGeneratorWithInitialValueAndSlotTrackingConfig = Readonly<{ @@ -9,18 +9,18 @@ type CreateAsyncGeneratorWithInitialValueAndSlotTrackingConfig>; + rpcRequest: RpcSendable>; /** - * A pending RPC subscription request whose notifications will be yielded as they arrive. + * An RPC subscription request whose notifications will be yielded as they arrive. * Each notification must be a {@link SolanaRpcResponse} so that its slot can be compared * with the initial RPC response and other notifications. */ - rpcSubscriptionRequest: PendingRpcSubscriptionsRequest>; + rpcSubscriptionRequest: RpcSubscribable>; /** * Maps the value from a subscription notification to the item type yielded by the generator. */ diff --git a/packages/kit/src/create-reactive-store-with-initial-value-and-slot-tracking.ts b/packages/kit/src/create-reactive-store-with-initial-value-and-slot-tracking.ts index e13468241..6fd1d1a89 100644 --- a/packages/kit/src/create-reactive-store-with-initial-value-and-slot-tracking.ts +++ b/packages/kit/src/create-reactive-store-with-initial-value-and-slot-tracking.ts @@ -1,5 +1,5 @@ -import type { PendingRpcRequest } from '@solana/rpc'; -import type { PendingRpcSubscriptionsRequest } from '@solana/rpc-subscriptions'; +import type { RpcSendable } from '@solana/rpc'; +import type { RpcSubscribable } from '@solana/rpc-subscriptions'; import type { SolanaRpcResponse } from '@solana/rpc-types'; import type { ReactiveState, ReactiveStreamStore } from '@solana/subscribable'; @@ -16,22 +16,17 @@ import type { ReactiveState, ReactiveStreamStore } from '@solana/subscribable'; */ export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig = Readonly<{ /** - * Triggering this abort signal will cancel the pending RPC request and subscription, and - * disconnect the store from further updates. - */ - abortSignal: AbortSignal; - /** - * A pending RPC request whose response will be used to set the store's initial state. + * A one-shot RPC request whose response will be used to set the store's initial state. * The response must be a {@link SolanaRpcResponse} so that its slot can be compared with * subscription notifications. */ - rpcRequest: PendingRpcRequest>; + rpcRequest: RpcSendable>; /** - * A pending RPC subscription request whose notifications will be used to keep the store - * up to date. Each notification must be a {@link SolanaRpcResponse} so that its slot can be + * An RPC subscription request whose notifications will be used to keep the store up to + * date. Each notification must be a {@link SolanaRpcResponse} so that its slot can be * compared with the initial RPC response and other notifications. */ - rpcSubscriptionRequest: PendingRpcSubscriptionsRequest>; + rpcSubscriptionRequest: RpcSubscribable>; /** * Maps the value from a subscription notification to the item type stored in the reactive store. */ @@ -42,10 +37,10 @@ export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig TItem; }>; -const LOADING_STATE: ReactiveState = Object.freeze({ +const IDLE_STATE: ReactiveState = Object.freeze({ data: undefined, error: undefined, - status: 'loading', + status: 'idle', }); /** @@ -59,16 +54,23 @@ const LOADING_STATE: ReactiveState = Object.freeze({ * * Things to note: * - * - `getUnifiedState()` starts in `status: 'loading'` until the first response or notification - * arrives. Once data arrives it transitions to `status: 'loaded'` with a - * {@link SolanaRpcResponse} containing the value and the slot context at which it was observed. + * - The returned store starts in `status: 'idle'`. Call + * {@link ReactiveStreamStore.connect | `connect()`} to fire the RPC request and open the + * subscription. + * - The store transitions through `loading` until the first response or notification arrives, + * then to `loaded` with a {@link SolanaRpcResponse} containing the value and the slot context + * at which it was observed. * - On error from either source, the store transitions to `status: 'error'` preserving the last * known value. Only the first error per connection window is captured. - * - Calling {@link ReactiveStreamStore.retry | `retry()`} while in `status: 'error'` re-sends the RPC - * request and re-subscribes to the subscription using a fresh inner abort signal. The store - * transitions through `status: 'retrying'` back to `loaded`/`error`. - * - Triggering the caller's abort signal disconnects the store permanently; subsequent `retry()` - * calls are no-ops. + * - A subsequent `connect()` aborts the current connection, transitions back to + * `status: 'loading'` (preserving the last known `data` and `error` for stale-while-revalidate), + * and re-fires the RPC request and subscription with a fresh inner abort signal. + * - {@link ReactiveStreamStore.reset | `reset()`} aborts the current connection and returns the + * store to `idle`, clearing `data` and `error`. + * - Attach a caller-provided cancellation source via + * {@link ReactiveStreamStore.withSignal | `withSignal()`} — `store.withSignal(signal).connect()` + * composes the signal with the per-connection controller. Aborting the caller's signal + * transitions the store to `error` with that abort reason. * * @param config * @@ -86,7 +88,6 @@ const LOADING_STATE: ReactiveState = Object.freeze({ * const myAddress = address('FnHyam9w4NZoWR6mKN1CuGBritdsEWZQa4Z4oawLZGxa'); * * const balanceStore = createReactiveStoreWithInitialValueAndSlotTracking({ - * abortSignal: AbortSignal.timeout(60_000), * rpcRequest: rpc.getBalance(myAddress, { commitment: 'confirmed' }), * rpcValueMapper: lamports => lamports, * rpcSubscriptionRequest: rpcSubscriptions.accountNotifications(myAddress), @@ -97,17 +98,17 @@ const LOADING_STATE: ReactiveState = Object.freeze({ * const state = balanceStore.getUnifiedState(); * if (state.status === 'error') { * console.error('Error:', state.error); - * balanceStore.retry(); + * balanceStore.connect(); * } else if (state.status === 'loaded') { * console.log(`Balance at slot ${state.data.context.slot}:`, state.data.value); * } * }); + * balanceStore.withSignal(AbortSignal.timeout(60_000)).connect(); * ``` * * @see {@link ReactiveStreamStore} */ export function createReactiveStoreWithInitialValueAndSlotTracking({ - abortSignal, rpcRequest, rpcValueMapper, rpcSubscriptionRequest, @@ -115,44 +116,76 @@ export function createReactiveStoreWithInitialValueAndSlotTracking): ReactiveStreamStore< SolanaRpcResponse > { - let currentState: ReactiveState> = LOADING_STATE; + let currentState: ReactiveState> = IDLE_STATE; let lastUpdateSlot = -1n; + let currentInnerController: AbortController | undefined; const subscribers = new Set<() => void>(); - const outerController = new AbortController(); - abortSignal.addEventListener('abort', () => outerController.abort(abortSignal.reason)); - function notify() { subscribers.forEach(cb => cb()); } - function connect() { - if (outerController.signal.aborted) return; + function setState(next: ReactiveState>) { + if ( + currentState.status === next.status && + currentState.data === next.data && + currentState.error === next.error + ) { + return; + } + currentState = next; + notify(); + } + + function performConnect(callerSignal: AbortSignal | undefined) { + // Abort any currently active connection before starting a fresh one. + currentInnerController?.abort(); + // If the caller's signal is already aborted, surface as error and bail. + if (callerSignal?.aborted) { + setState({ data: currentState.data, error: callerSignal.reason, status: 'error' }); + return; + } + // Transition to `loading`, preserving the last known `data` and `error` for SWR. If + // already `loading` with the same data/error, `setState` no-ops — no spurious notify. + setState({ data: currentState.data, error: currentState.error, status: 'loading' }); + const innerController = new AbortController(); - const forwardAbort = () => innerController.abort(outerController.signal.reason); - outerController.signal.addEventListener('abort', forwardAbort, { signal: innerController.signal }); + currentInnerController = innerController; const innerSignal = innerController.signal; + const signal = callerSignal ? AbortSignal.any([innerSignal, callerSignal]) : innerSignal; + // Caller's signal aborting (not just supersede via the inner controller) transitions the + // store to error with the caller's abort reason. Scoped to the inner signal so the + // listener is removed on reconnect / reset. + if (callerSignal) { + callerSignal.addEventListener( + 'abort', + () => { + if (innerSignal.aborted) return; + setState({ data: currentState.data, error: callerSignal.reason, status: 'error' }); + innerController.abort(callerSignal.reason); + }, + { signal: innerSignal }, + ); + } function handleError(err: unknown) { - if (innerSignal.aborted) return; + if (signal.aborted) return; if (currentState.status === 'error') return; - currentState = { data: currentState.data, error: err, status: 'error' }; + setState({ data: currentState.data, error: err, status: 'error' }); innerController.abort(err); - notify(); } function handleValue(value: SolanaRpcResponse) { - currentState = { data: value, error: undefined, status: 'loaded' }; - notify(); + setState({ data: value, error: undefined, status: 'loaded' }); } rpcRequest - .send({ abortSignal: innerSignal }) + .send({ abortSignal: signal }) .then(({ context: { slot }, value }) => { - if (innerSignal.aborted) return; - // `lastUpdateSlot` persists across retries so the store never regresses. If the - // retried RPC returns a slot older than one we've already seen, we wait for the - // subscription to deliver something newer before leaving `retrying`. + if (signal.aborted) return; + // `lastUpdateSlot` persists across reconnects so the store never regresses. If + // the re-fetched RPC returns a slot older than one we've already seen, we wait + // for the subscription to deliver something newer before leaving `loading`. if (slot < lastUpdateSlot) return; lastUpdateSlot = slot; handleValue({ context: { slot }, value: rpcValueMapper(value) }); @@ -160,13 +193,13 @@ export function createReactiveStoreWithInitialValueAndSlotTracking { for await (const { context: { slot }, value, } of notifications) { - if (innerSignal.aborted) return; + if (signal.aborted) return; if (slot < lastUpdateSlot) continue; lastUpdateSlot = slot; handleValue({ context: { slot }, value: rpcSubscriptionValueMapper(value) }); @@ -175,9 +208,18 @@ export function createReactiveStoreWithInitialValueAndSlotTracking> { return currentState; }, + reset: performReset, retry(): void { - if (outerController.signal.aborted) return; if (currentState.status !== 'error') return; - currentState = { data: currentState.data, error: undefined, status: 'retrying' }; - notify(); - connect(); + performConnect(undefined); }, subscribe(callback: () => void): () => void { subscribers.add(callback); @@ -200,5 +240,12 @@ export function createReactiveStoreWithInitialValueAndSlotTracking + + + ); +} +``` + +The `client` reference must be stable across renders — build it at module scope, or memoise it with `useMemo` when its config is reactive (e.g. a cluster toggle). + +When a plugin's `.use()` is async, `createClient().use(...)` returns a promise. Pass it directly; the provider suspends via the nearest `` boundary until it resolves. + +```tsx +import { Suspense, useMemo } from 'react'; + +function Root() { + const clientPromise = useMemo(() => createClient().use(someAsyncPlugin()), []); + return ( + }> + + + + + ); +} +``` + +### `useClient()` + +Reads the Kit client published by the nearest `ClientProvider`. Throws a `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_PROVIDER` if no provider is mounted. + +Defaults to the base `Client` shape. Callers who know a specific plugin is installed may widen the type via the generic — this is a pure cast with no runtime check, so reach for `useClientCapability` when a missing plugin should fail loudly at mount instead of surfacing later as `undefined`. + +```tsx +import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; +import { useClient } from '@solana/react'; + +function ManualSend() { + const client = useClient>(); + return ; +} +``` + +### `useClientCapability(config)` + +Reads the client and asserts at mount that the requested capability is installed, narrowing the return type via the generic. Throws a `SolanaError` with code `SOLANA_ERROR__REACT__MISSING_CAPABILITY` when the capability is absent — including `hookName` and `providerHint` so users can fix the mistake without cross-referencing docs. + +Use this from the implementation of plugin-specific hooks. Apps that need ad-hoc access can reach for `useClient` directly and supply their own narrowing. + +```tsx +import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; +import { useClientCapability } from '@solana/react'; + +function useRpc() { + return useClientCapability>({ + capability: 'rpc', + hookName: 'useRpc', + providerHint: 'Install `solanaRpc()` on the client.', + }); +} +``` + +Pass an array of capability names when a hook needs more than one (e.g. `['rpc', 'rpcSubscriptions']`) — the same `providerHint` is surfaced for whichever is missing. + +### `useAction(fn)` + +Bridges any async function into a tracked action with `dispatch` / `status` / `data` / `error` / `reset`. Each `dispatch(...)` runs `fn` with a fresh `AbortSignal` and tracks the lifecycle through React state; calling `dispatch` again while a prior call is in flight aborts the first. + +`fn` is held in a ref that always points at the latest closure — there is no `deps` array to maintain. Each `dispatch(...)` invokes the most recently rendered `fn`, so values captured inside (e.g. form state, route params) are always fresh. In-flight calls are unaffected — they continue with the closure they captured at dispatch time. + +```tsx +import { useAction } from '@solana/react'; +import { isAbortError } from '@solana/promises'; + +function PostMessageButton({ url, body }: { url: string; body: string }) { + const { dispatch, isRunning, error } = useAction(async (signal, content: string) => { + const res = await fetch(url, { body: content, method: 'POST', signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }); + + return ( + + ); +} +``` + +`dispatch` returns `Promise`. Fire-and-forget callers can ignore it and render from `status` / `data` / `error`. Awaiters that read the resolved value (e.g. to navigate on success) should filter superseded calls with `isAbortError` from `@solana/promises`: + +```tsx +try { + const { id } = await dispatch(body); + navigate(`/messages/${id}`); +} catch (err) { + if (isAbortError(err)) return; // superseded — state already reflects the newer call + // handle real error +} +``` + +### `useRequest(source, options?)` + +Fires a one-shot request on mount and re-fires whenever `source` changes identity. Returns `{ data, error, status, refresh }` where `status` is one of `'fetching' | 'success' | 'error' | 'disabled'`. Use it for RPC reads, or for any other one-shot async work an app needs (a `fetch`, a third-party SDK call, etc.). + +`source` is either an async function `(signal: AbortSignal) => Promise` (most general), or any reactive store source `{ reactiveStore(): ReactiveActionStore<[], T> }` — `PendingRpcRequest` is the canonical implementation. Pass `null` to disable (the result reports `status: 'disabled'`). + +> Unlike `useAction`, `useRequest` needs the input to have stable identity across renders — it's how the hook knows when to re-fire. Memoize with `useMemo` (for a reactive store source) or `useCallback` (for a function), keyed on whatever inputs your call depends on. + +```tsx +import { useClient, useRequest } from '@solana/react'; +import type { ClientWithRpc, GetLatestBlockhashApi } from '@solana/kit'; + +function LatestBlockhash() { + const client = useClient>(); + const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]); + const { data, error, refresh } = useRequest(source); + if (error) return ; + return

{data ? `Blockhash: ${data.value.blockhash}` : 'Loading…'}

; +} +``` + +`refresh()` re-fires the request manually. While a refresh is in flight, `status` returns to `'fetching'` and `data` / `error` from the prior outcome stay populated until the new attempt resolves (stale-while-revalidate). On the first attempt both are `undefined`. + +```tsx +function Balance({ address }: { address: Address | null }) { + const client = useClient>(); + // Disabled until an address is selected. + const source = useMemo(() => (address ? client.rpc.getBalance(address) : null), [client, address]); + const { data, status } = useRequest(source); + if (status === 'disabled') return

Select an account to see its balance.

; + return

{data?.value !== undefined ? `${data.value} lamports` : 'Loading…'}

; +} +``` + +For any other one-shot async work — `fetch`, a third-party SDK call, or anything that isn't a `ReactiveActionSource` — pass an async function instead of a source. The `signal` argument fires when the request is superseded, the source changes, or the component unmounts; thread it into your call's own abort plumbing: + +```tsx +function Profile({ userId }: { userId: string }) { + const fetcher = useCallback( + (signal: AbortSignal) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()), + [userId], + ); + const { data, error, refresh } = useRequest(fetcher); + if (error) return ; + return

{data ? data.name : 'Loading…'}

; +} +``` + +#### Per-attempt cancellation + +Pass `getAbortSignal` to attach a cancellation signal to each individual attempt — initial fire plus every `refresh()`. The natural use is per-attempt timeouts: + +```tsx +const { data, error, refresh } = useRequest(source, { + // Each attempt gets a fresh 5-second clock. `refresh()` resets it. + getAbortSignal: () => AbortSignal.timeout(5_000), +}); +``` + +The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. To kill the hook entirely (e.g. on a route change), set the memoized source to `null` (the result reports `disabled`), or let the component unmount. + +`refresh()` accepts an optional `{ abortSignal }` override that replaces the configured factory for just that attempt — useful when one specific refresh needs different cancellation semantics: + +```tsx +const userInitiatedCtrl = new AbortController(); +refresh({ abortSignal: userInitiatedCtrl.signal }); // override: use this signal, ignore the factory +refresh({ abortSignal: undefined }); // no abort signal for this attempt +refresh(); // omit the key to use the factory (default) +``` + +### `useSubscription(source, options?)` + +Subscribe to a stream-store source and surface the latest notification as reactive state. Returns `{ data, error, reconnect, status }` where `status` is one of `'loading' | 'loaded' | 'error' | 'disabled'`. Use it for any RPC subscription (`accountNotifications`, `slotNotifications`, `logsNotifications`, etc.) or any plugin-authored stream that satisfies `ReactiveStreamSource`. + +`source` is any `ReactiveStreamSource` — the `{ reactiveStore() }` duck-type satisfied by `PendingRpcSubscriptionsRequest`. Pass `null` to disable. Memoize the source with `useMemo` keyed on whatever inputs it depends on; stable identity is how the hook knows when to tear down and re-open. + +`data` is the notification as the source emits it. For RPC subscriptions that emit `SolanaRpcResponse`, read the inner value at `data.value` and the slot at `data.context.slot`. For raw notifications, `data` is the raw shape. + +```tsx +import { useClient, useSubscription } from '@solana/react'; +import type { Address, AccountNotificationsApi, ClientWithRpcSubscriptions } from '@solana/kit'; + +function AccountBalance({ address }: { address: Address }) { + const client = useClient>(); + const source = useMemo(() => client.rpcSubscriptions.accountNotifications(address), [client, address]); + const { data, error, reconnect } = useSubscription(source); + if (error) return ; + return

{data ? `${data.value.lamports} lamports at slot ${data.context.slot}` : 'Connecting…'}

; +} +``` + +`reconnect()` re-opens the connection. After a `loaded` outcome that transitions to `error`, calling `reconnect()` returns `status` to `'loading'` while preserving the stale `data` and `error` (stale-while-revalidate) → `'loaded'` (or `'error'` again). The hook tears the connection down on unmount via the store's `reset()`; StrictMode's mount → unmount → mount cycle re-opens cleanly. + +#### Per-connection cancellation + +Pass `getAbortSignal` to attach a cancellation signal to each individual connection — initial subscribe plus every `reconnect()`. The natural use is per-connection timeouts: + +```tsx +const { data, error, reconnect } = useSubscription(source, { + // Each connection gets a fresh 30-second clock. `reconnect()` resets it. + getAbortSignal: () => AbortSignal.timeout(30_000), +}); +``` + +The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. To kill the subscription entirely (e.g. on a route change), set the memoized source to `null` (the result reports `disabled`), or let the component unmount. + +`reconnect()` accepts an optional `{ abortSignal }` override that replaces the configured factory for just that attempt — useful when one specific reconnect needs different cancellation semantics: + +```tsx +const userInitiatedCtrl = new AbortController(); +reconnect({ abortSignal: userInitiatedCtrl.signal }); // override: use this signal, ignore the factory +reconnect({ abortSignal: undefined }); // no abort signal for this attempt +reconnect(); // omit the key to use the factory (default) +``` + +### `useTrackedData(spec, options?)` + +Render reactive state 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 underlying kit primitive's `SolanaRpcResponse` envelope (the primitive's type guarantees the shape, so callers can read `data.value` and `data.context.slot` directly) and `status` is one of `'loading' | 'loaded' | 'error' | 'disabled'`. The underlying store slot-dedupes between the two sources — out-of-order arrivals never regress the surfaced value. + +`spec` is a `TrackedDataSpec` with four fields: a pending RPC request, a pending RPC subscription request, and two mappers that unify their value shapes into a common `TItem`. Both RPC responses and subscription notifications must have shape `SolanaRpcResponse` for slot de-dupe. Pass `null` to disable (the result reports `status: 'disabled'`). Memoize the spec with `useMemo` keyed on its inputs — stable identity is how the hook knows when to tear down and re-run. + +```tsx +import { useClient, useTrackedData } from '@solana/react'; +import type { + Address, + AccountNotificationsApi, + ClientWithRpc, + ClientWithRpcSubscriptions, + GetBalanceApi, +} from '@solana/kit'; + +function AccountBalance({ address }: { address: Address }) { + const client = useClient & ClientWithRpcSubscriptions>(); + 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 ; + return

{data ? `${data.value} lamports at slot ${data.context.slot}` : 'Loading…'}

; +} +``` + +`refresh()` re-runs both the initial RPC and the subscription. While a refresh is in flight, `status` returns to `'loading'` and `data` / `error` from the prior outcome stay populated until the new attempt resolves (stale-while-revalidate). `data.context.slot` is the slot the underlying store dedup'd on and stays paired with `data.value` across status transitions — useful for "data as of slot X" UIs. + +#### Per-attempt cancellation + +Pass `getAbortSignal` to attach a cancellation signal to each attempt — initial run plus every `refresh()`. The natural use is per-attempt timeouts: + +```tsx +const { data, error, refresh } = useTrackedData(spec, { + // Each attempt gets a fresh 30-second clock. `refresh()` resets it. + getAbortSignal: () => AbortSignal.timeout(30_000), +}); +``` + +The factory is held in a ref synced to the latest render, so inline closures are fine — no `useCallback` needed. To kill the hook entirely (e.g. on a route change), set the memoized spec to `null` (the result reports `disabled`), or let the component unmount. + +`refresh()` accepts an optional `{ abortSignal }` override that replaces the configured factory for just that attempt: + +```tsx +const userInitiatedCtrl = new AbortController(); +refresh({ abortSignal: userInitiatedCtrl.signal }); // override: use this signal, ignore the factory +refresh({ abortSignal: undefined }); // no abort signal for this attempt +refresh(); // omit the key to use the factory (default) +``` + +## SWR adapter (`@solana/react/swr`) + +Opt-in subpath that bridges Kit's reactive primitives into SWR's cache. Import from `@solana/react/swr`; `swr@^2` is an optional peer dependency. Hooks carry the `Swr` suffix to keep the cache backing visible at the call site. + +### `useRequestSwr(key, source, options?)` + +SWR-backed counterpart to `useRequest`. Same `source` shape (a `ReactiveActionSource` or `(signal: AbortSignal) => Promise`). Returns SWR's native `SWRResponse`. Pass `null` for either `key` or `source` to disable — useful when one of the source's inputs isn't yet known. + +```tsx +import { useClient } from '@solana/react'; +import { useRequestSwr } from '@solana/react/swr'; +import type { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; + +function EpochInfo() { + const client = useClient>(); + const { data } = useRequestSwr(['epochInfo'], client.rpc.getEpochInfo()); + return

{data ? `Epoch ${data.epoch}` : 'Loading…'}

; +} +``` + +Pass any SWR `SWRConfiguration` field in the options bag (`revalidateOnFocus`, `refreshInterval`, `suspense`, etc.). Use `result.mutate()` to re-fire on demand — SWR owns the revalidate verb. + +The Kit-only `getAbortSignal: () => AbortSignal` option threads a per-attempt signal into the source (typically a timeout). SWR doesn't know about the abort; if the source respects the signal and rejects, SWR surfaces the rejection via `result.error`. + +```tsx +useRequestSwr(['epochInfo'], source, { + getAbortSignal: () => AbortSignal.timeout(5_000), +}); +``` + +### `useSubscriptionSwr(key, source, options?)` + +SWR-backed counterpart to `useSubscription`. Routes a `ReactiveStreamSource` through SWR's subscription cache (`useSWRSubscription`). Returns SWR's native `{ data, error }` shape where `data` is a `SlotTaggedValue` — `data.value` is the unwrapped notification and `data.slot` is lifted from the envelope's `context.slot` (or `undefined` for raw notifications). Pass `null` for either `key` or `source` to disable. Options accept SWR's config plus `getAbortSignal` for per-connection signals. + +```tsx +function AccountBalance({ address }: { address: Address }) { + const client = useClient>(); + const { data } = useSubscriptionSwr( + address ? ['account', address] : null, + address ? client.rpcSubscriptions.accountNotifications(address) : null, + ); + return

{data ? `${data.value.lamports} lamports at slot ${data.slot}` : 'Connecting…'}

; +} +``` + ## Hooks ### `useSignIn(uiWalletAccount, chain)` diff --git a/packages/react/package.json b/packages/react/package.json index 3061a812b..be0965f89 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -4,28 +4,52 @@ "description": "React hooks for building Solana apps", "homepage": "https://www.solanakit.com/api#solanareact", "exports": { - "edge-light": { - "import": "./dist/index.node.mjs", - "require": "./dist/index.node.cjs" + ".": { + "edge-light": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "workerd": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts" }, - "workerd": { - "import": "./dist/index.node.mjs", - "require": "./dist/index.node.cjs" - }, - "browser": { - "import": "./dist/index.browser.mjs", - "require": "./dist/index.browser.cjs" - }, - "node": { - "import": "./dist/index.node.mjs", - "require": "./dist/index.node.cjs" - }, - "react-native": "./dist/index.native.mjs", - "types": "./dist/types/index.d.ts" + "./swr": { + "edge-light": { + "import": "./dist/swr.node.mjs", + "require": "./dist/swr.node.cjs" + }, + "workerd": { + "import": "./dist/swr.node.mjs", + "require": "./dist/swr.node.cjs" + }, + "browser": { + "import": "./dist/swr.browser.mjs", + "require": "./dist/swr.browser.cjs" + }, + "node": { + "import": "./dist/swr.node.mjs", + "require": "./dist/swr.node.cjs" + }, + "react-native": "./dist/swr.native.mjs", + "types": "./dist/types/swr.d.ts" + } }, "browser": { "./dist/index.node.cjs": "./dist/index.browser.cjs", - "./dist/index.node.mjs": "./dist/index.browser.mjs" + "./dist/index.node.mjs": "./dist/index.browser.mjs", + "./dist/swr.node.cjs": "./dist/swr.browser.cjs", + "./dist/swr.node.mjs": "./dist/swr.browser.mjs" }, "main": "./dist/index.node.cjs", "module": "./dist/index.node.mjs", @@ -74,13 +98,7 @@ "maintained node versions" ], "dependencies": { - "@solana/addresses": "workspace:*", - "@solana/errors": "workspace:*", - "@solana/keys": "workspace:*", "@solana/promises": "workspace:*", - "@solana/signers": "workspace:*", - "@solana/transaction-messages": "workspace:*", - "@solana/transactions": "workspace:*", "@solana/wallet-standard-features": "^1.3.0", "@wallet-standard/base": "^1.1.0", "@wallet-standard/errors": "^0.1.1", @@ -89,23 +107,29 @@ "@wallet-standard/ui-registry": "^1.0.1" }, "devDependencies": { - "@solana/codecs-core": "workspace:*", "@solana/eslint-config": "workspace:*", - "@solana/rpc-types": "workspace:*", + "@solana/kit": "workspace:*", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@types/react-test-renderer": "^19.1.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-error-boundary": "^5.0.0", - "react-test-renderer": "^19.2.6" + "react-test-renderer": "^19.2.6", + "swr": "^2.4.1" }, "peerDependencies": { - "react": ">=18" + "@solana/kit": "workspace:*", + "react": ">=18", + "swr": "^2.4.1" }, "peerDependenciesMeta": { "react": { "optional": true + }, + "swr": { + "optional": true } }, "engines": { diff --git a/packages/react/src/ClientProvider.tsx b/packages/react/src/ClientProvider.tsx new file mode 100644 index 000000000..aa2fcab22 --- /dev/null +++ b/packages/react/src/ClientProvider.tsx @@ -0,0 +1,77 @@ +import type { Client } from '@solana/kit'; +import React from 'react'; + +import { usePromise } from './usePromise'; + +const ClientContext = /*#__PURE__*/ React.createContext | null>(null); + +/** + * The React context that holds the Kit client published by the nearest {@link ClientProvider}. + * Exported for advanced cases such as third-party providers that wrap and extend the client; most + * consumers should reach for {@link useClient} or one of the higher-level hooks instead. + */ +export { ClientContext }; + +/** + * Props accepted by {@link ClientProvider}. + */ +export type ClientProviderProps = Readonly<{ + children?: React.ReactNode; + /** + * The Kit client to publish to descendants, or a promise resolving to one (e.g. when the + * client has async plugins). The reference must be stable across renders — build it at + * module scope or memoise it with `useMemo` when its config is reactive. + */ + client: Client | Promise>; +}>; + +/** + * Publishes a caller-owned Kit client to its subtree. Required for `useClient`, + * `useClientCapability`, and any plugin-specific hook that depends on a client capability. + * + * Plugin composition belongs in plain Kit — the provider does no composition, lifecycle + * management, or disposal; it is a value channel, not a lifecycle channel. When config changes at + * runtime (e.g. cluster toggle), rebuild the client in `useMemo` and pass the new reference; the + * subtree resubscribes against the new client identity. + * + * Async client support: when `client` is a promise (e.g. `createClient().use(asyncPlugin())`), + * the provider suspends the subtree via the nearest `` boundary until the promise + * resolves. On React 19 this delegates to `React.use(promise)`; on React 18 a thrown-promise shim + * keyed by promise identity preserves the same contract. + * + * @example Sync client + * ```tsx + * import { createClient } from '@solana/kit'; + * import { ClientProvider } from '@solana/react'; + * + * const client = createClient(); // .use(...) plugins as needed + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + * + * @example Async client (Suspense) + * ```tsx + * const clientPromise = useMemo( + * () => createClient().use(someAsyncPlugin()), + * [], + * ); + * + * }> + * + * + * + * + * ``` + * + * @see {@link useClient} + */ +export function ClientProvider({ children, client }: ClientProviderProps): React.ReactElement { + const resolved = usePromise(client); + return {children}; +} diff --git a/packages/react/src/__test-utils__/render.tsx b/packages/react/src/__test-utils__/render.tsx new file mode 100644 index 000000000..a820fd5a2 --- /dev/null +++ b/packages/react/src/__test-utils__/render.tsx @@ -0,0 +1,41 @@ +import React, { ComponentType, ReactElement, ReactNode, StrictMode } from 'react'; +import { + render as baseRender, + renderHook as baseRenderHook, + RenderHookOptions, + RenderOptions, +} from '@testing-library/react'; + +/** + * Shared test renderers that wrap every React tree in ``. + * + * StrictMode's dev double-render surfaces render-phase impurity (side effects in `useMemo` or + * `useState` initializers, missing effect cleanups, refs read during render) that would + * otherwise only manifest in real apps. Using these helpers across all React hook / component + * tests catches that class of bug at test time. + * + * Composes with caller-supplied wrappers: `renderHook(() => useFoo(), { wrapper: Provider })` + * still works — the `Provider` is rendered inside `StrictMode`. + * + * Re-export from this module rather than `@testing-library/react` directly so the StrictMode + * wrap is automatic. + */ + +function composeWithStrictMode( + Inner: ComponentType<{ children: ReactNode }> | undefined, +): ComponentType<{ children: ReactNode }> { + return function StrictModeWrapper({ children }) { + return {Inner ? {children} : children}; + }; +} + +export function renderHook( + callback: (props: TProps) => TResult, + options?: RenderHookOptions, +): ReturnType> { + return baseRenderHook(callback, { ...options, wrapper: composeWithStrictMode(options?.wrapper) }); +} + +export function render(ui: ReactElement, options?: RenderOptions): ReturnType { + return baseRender(ui, { ...options, wrapper: composeWithStrictMode(options?.wrapper) }); +} diff --git a/packages/react/src/__tests__/ClientProvider-test.browser.tsx b/packages/react/src/__tests__/ClientProvider-test.browser.tsx new file mode 100644 index 000000000..3ac99d656 --- /dev/null +++ b/packages/react/src/__tests__/ClientProvider-test.browser.tsx @@ -0,0 +1,141 @@ +import { Client, createClient, isSolanaError, SOLANA_ERROR__REACT__MISSING_PROVIDER } from '@solana/kit'; +import { act } from '@testing-library/react'; +import React, { Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { render, renderHook } from '../__test-utils__/render'; +import { ClientProvider } from '../ClientProvider'; +import { useClient } from '../useClient'; + +describe('ClientProvider + useClient', () => { + it('publishes the client to descendants and returns the same reference across renders', () => { + const client = createClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result, rerender } = renderHook(() => useClient(), { wrapper }); + expect(result.current).toBe(client); + rerender(); + expect(result.current).toBe(client); + }); + + it('throws SolanaError MISSING_PROVIDER when `useClient` is called outside a provider', () => { + const { result } = renderHook(() => { + try { + return useClient(); + } catch (err) { + return err; + } + }); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_PROVIDER)).toBe(true); + expect((result.current as { context: { hookName: string } }).context.hookName).toBe('useClient'); + }); + + it('lets the nearest provider win for nested mounts', () => { + const outer = createClient(); + const inner = createClient(); + // Separate spies per probe — StrictMode renders each probe twice, so a single shared spy + // can't be ordered (we'd see outer, outer, inner, inner or interleaved). Per-probe spies + // let us assert each probe only ever saw its own provider's client. + const onRenderOuter = jest.fn(); + const onRenderInner = jest.fn(); + function OuterProbe() { + onRenderOuter(useClient()); + return null; + } + function InnerProbe() { + onRenderInner(useClient()); + return null; + } + render( + + + + + + , + ); + expect(onRenderOuter).toHaveBeenCalledWith(outer); + expect(onRenderOuter).not.toHaveBeenCalledWith(inner); + expect(onRenderInner).toHaveBeenCalledWith(inner); + expect(onRenderInner).not.toHaveBeenCalledWith(outer); + }); + + describe('async client', () => { + it('renders the children once the client promise has resolved', async () => { + const client = createClient(); + const clientPromise = Promise.resolve(client); + const onRender = jest.fn(); + function Probe() { + onRender(useClient()); + return
ready
; + } + let queryByTestId!: ReturnType['queryByTestId']; + await act(async () => { + ({ queryByTestId } = render( + loading}> + + + + , + )); + }); + expect(queryByTestId('fallback')).toBeNull(); + expect(queryByTestId('probe')).not.toBeNull(); + expect(onRender).toHaveBeenLastCalledWith(client); + }); + + it('suspends while the promise is pending', () => { + const clientPromise = new Promise>(() => { + /* never resolves */ + }); + function Probe() { + useClient(); + return
ready
; + } + const { queryByTestId } = render( + loading}> + + + + , + ); + expect(queryByTestId('fallback')).not.toBeNull(); + expect(queryByTestId('probe')).toBeNull(); + }); + + it('lets a rejected client promise propagate to the nearest error boundary', async () => { + const boom = new Error('boom'); + const clientPromise = Promise.reject>(boom); + // Pre-attach a catch so the rejection isn't flagged as unhandled before React's + // error-boundary subscription runs. + clientPromise.catch(() => { }); + const onError = jest.fn(); + function Probe() { + useClient(); + return
ready
; + } + let queryByTestId!: ReturnType['queryByTestId']; + await act(async () => { + ({ queryByTestId } = render( + { + onError(error); + return
{(error as Error).message}
; + }} + > + loading}> + + + + +
, + )); + }); + expect(queryByTestId('caught')).not.toBeNull(); + expect(queryByTestId('caught')!.textContent).toBe('boom'); + expect(queryByTestId('probe')).toBeNull(); + expect(onError).toHaveBeenCalledWith(boom); + }); + }); +}); diff --git a/packages/react/src/__tests__/staticStores-test.ts b/packages/react/src/__tests__/staticStores-test.ts new file mode 100644 index 000000000..6f8b039ee --- /dev/null +++ b/packages/react/src/__tests__/staticStores-test.ts @@ -0,0 +1,112 @@ +import { disabledActionStore, disabledStreamStore } from '../staticStores'; + +describe('disabledActionStore', () => { + it('reports a frozen `idle` state with no data or error', () => { + const store = disabledActionStore(); + const state = store.getState(); + expect(state).toEqual({ data: undefined, error: undefined, status: 'idle' }); + expect(Object.isFrozen(state)).toBe(true); + }); + + it('returns the same state reference across calls', () => { + const store = disabledActionStore(); + expect(store.getState()).toBe(store.getState()); + }); + + it('`dispatch()` is a no-op — state does not change', () => { + const store = disabledActionStore(); + const before = store.getState(); + store.dispatch(); + store.dispatch(); + expect(store.getState()).toBe(before); + }); + + it('`reset()` is a no-op — state does not change', () => { + const store = disabledActionStore(); + const before = store.getState(); + store.reset(); + expect(store.getState()).toBe(before); + }); + + it('`withSignal(signal).dispatch()` is a no-op — state does not change, signal is not observed', () => { + const store = disabledActionStore(); + const ctrl = new AbortController(); + const before = store.getState(); + store.withSignal(ctrl.signal).dispatch(); + ctrl.abort(new Error('would-be-cancellation')); + expect(store.getState()).toBe(before); + }); + + it('`subscribe()` never notifies (dispatch + reset produce no state change to observe)', () => { + const store = disabledActionStore(); + const listener = jest.fn(); + const unsubscribe = store.subscribe(listener); + store.dispatch(); + store.reset(); + store.withSignal(new AbortController().signal).dispatch(); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('`dispatchAsync()` rejects with an AbortError so accidental awaits surface, not silently hang', async () => { + const store = disabledActionStore(); + await expect(store.dispatchAsync()).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); + +describe('disabledStreamStore', () => { + it('reports a frozen `idle` state with no data or error', () => { + const store = disabledStreamStore(); + const state = store.getUnifiedState(); + expect(state).toEqual({ data: undefined, error: undefined, status: 'idle' }); + expect(Object.isFrozen(state)).toBe(true); + }); + + it('returns the same getUnifiedState() reference across calls', () => { + const store = disabledStreamStore(); + expect(store.getUnifiedState()).toBe(store.getUnifiedState()); + }); + + it('`connect()` is a no-op — state does not change', () => { + const store = disabledStreamStore(); + const before = store.getUnifiedState(); + store.connect(); + store.connect(); + expect(store.getUnifiedState()).toBe(before); + }); + + it('`reset()` is a no-op — state does not change', () => { + const store = disabledStreamStore(); + const before = store.getUnifiedState(); + store.reset(); + expect(store.getUnifiedState()).toBe(before); + }); + + it('`retry()` is a no-op — state does not change', () => { + const store = disabledStreamStore(); + const before = store.getUnifiedState(); + store.retry(); + expect(store.getUnifiedState()).toBe(before); + }); + + it('`withSignal(signal).connect()` is a no-op — state does not change, signal is not observed', () => { + const store = disabledStreamStore(); + const ctrl = new AbortController(); + const before = store.getUnifiedState(); + store.withSignal(ctrl.signal).connect(); + ctrl.abort(new Error('would-be-cancellation')); + expect(store.getUnifiedState()).toBe(before); + }); + + it('`subscribe()` never notifies (connect + reset produce no state change to observe)', () => { + const store = disabledStreamStore(); + const listener = jest.fn(); + const unsubscribe = store.subscribe(listener); + store.connect(); + store.reset(); + store.retry(); + store.withSignal(new AbortController().signal).connect(); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); +}); diff --git a/packages/react/src/__tests__/useAction-test.browser.tsx b/packages/react/src/__tests__/useAction-test.browser.tsx new file mode 100644 index 000000000..0d76996b4 --- /dev/null +++ b/packages/react/src/__tests__/useAction-test.browser.tsx @@ -0,0 +1,176 @@ +import { isAbortError } from '@solana/promises'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { renderHook } from '../__test-utils__/render'; +import { useAction } from '../useAction'; + +describe('useAction', () => { + it('transitions idle → running → success', async () => { + const { promise, resolve } = Promise.withResolvers(); + const fn = jest.fn((_s: AbortSignal, _arg: string) => promise); + const { result } = renderHook(() => useAction(fn)); + + expect(result.current.status).toBe('idle'); + expect(result.current.data).toBeUndefined(); + + act(() => { + void result.current.dispatch('hello'); + }); + expect(result.current.status).toBe('running'); + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal), 'hello'); + + await act(async () => resolve('world')); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe('world'); + }); + + it('transitions idle → running → error', async () => { + const boom = new Error('boom'); + const { promise, reject } = Promise.withResolvers(); + const { result } = renderHook(() => useAction((_s: AbortSignal) => promise)); + + act(() => { + result.current.dispatch().catch(() => {}); + }); + expect(result.current.status).toBe('running'); + + await act(async () => reject(boom)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + }); + + it('aborts the prior call when dispatch is invoked again while one is in flight', async () => { + const fn = jest.fn((signal: AbortSignal) => { + const { promise, reject } = Promise.withResolvers(); + signal.addEventListener('abort', () => reject(signal.reason)); + return promise; + }); + const { result } = renderHook(() => useAction(fn)); + + let firstCall!: Promise; + act(() => { + firstCall = result.current.dispatch(); + }); + // Pre-attach a no-op catch so the second call's eventual abort rejection (when the hook + // unmounts) doesn't surface as an unhandled rejection. + act(() => { + result.current.dispatch().catch(() => {}); + }); + + expect(fn.mock.calls[0][0].aborted).toBe(true); + expect(fn.mock.calls[1][0].aborted).toBe(false); + + const firstError = await firstCall.catch((err: unknown) => err); + expect(isAbortError(firstError)).toBe(true); + }); + + it('await dispatch(...) resolves to the function result on success', async () => { + const { result } = renderHook(() => useAction(async (_s: AbortSignal, n: number) => n * 2)); + await act(async () => { + await expect(result.current.dispatch(21)).resolves.toBe(42); + }); + expect(result.current.data).toBe(42); + }); + + it('reset() returns to idle and clears data', async () => { + const { result } = renderHook(() => useAction(async () => 'hi')); + await act(async () => { + await result.current.dispatch(); + }); + expect(result.current.data).toBe('hi'); + + act(() => result.current.reset()); + expect(result.current.status).toBe('idle'); + expect(result.current.data).toBeUndefined(); + }); + + it('keeps prior error through a subsequent running state (stale-while-revalidate)', async () => { + const boom = new Error('boom'); + const { promise: secondPending, resolve: resolveSecond } = Promise.withResolvers(); + let n = 0; + const fn = () => (++n === 1 ? Promise.reject(boom) : secondPending); + const { result } = renderHook(() => useAction(fn)); + + await act(async () => { + await result.current.dispatch().catch(() => {}); + }); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + + act(() => { + void result.current.dispatch(); + }); + expect(result.current.status).toBe('running'); + expect(result.current.error).toBe(boom); // stale error preserved during revalidation + + await act(async () => resolveSecond('ok')); + expect(result.current.status).toBe('success'); + expect(result.current.error).toBeUndefined(); + }); + + it('keeps prior data through a subsequent running state (stale-while-revalidate)', async () => { + const { promise: secondPending, resolve: resolveSecond } = Promise.withResolvers(); + let n = 0; + const fn = () => (++n === 1 ? Promise.resolve('first') : secondPending); + const { result } = renderHook(() => useAction(fn)); + + await act(async () => { + await result.current.dispatch(); + }); + expect(result.current.data).toBe('first'); + + act(() => { + void result.current.dispatch(); + }); + expect(result.current.status).toBe('running'); + expect(result.current.data).toBe('first'); // stale data preserved during revalidation + + await act(async () => resolveSecond('second')); + expect(result.current.data).toBe('second'); + }); + + it('uses the latest fn closure on each new call', async () => { + let captured: number | null = null; + const { result, rerender } = renderHook(({ value }: { value: number }) => useAction(async () => (captured = value)), { initialProps: { value: 1 } }); + + await act(async () => { + await result.current.dispatch(); + }); + expect(captured).toBe(1); + + rerender({ value: 2 }); + await act(async () => { + await result.current.dispatch(); + }); + expect(captured).toBe(2); + }); + + it('keeps stable dispatch / reset references across re-renders even as fn changes', () => { + const { result, rerender } = renderHook(({ tag }: { tag: string }) => useAction(async () => tag), { + initialProps: { tag: 'a' }, + }); + const { dispatch, reset } = result.current; + + rerender({ tag: 'b' }); + expect(result.current.dispatch).toBe(dispatch); + expect(result.current.reset).toBe(reset); + }); + + describe('SSR', () => { + it('renders `idle` on the server without invoking the wrapped function', () => { + const fn = jest.fn(async () => 'never'); + function Component() { + const { status } = useAction(fn); + return

{status}

; + } + // `renderToString` drives `useSyncExternalStore` through its server-snapshot path + // (the third arg to `useSyncExternalStore`), and effects don't run during server + // rendering — so the store stays `idle` and dispatch is never called. + const html = renderToString(); + expect(html).toBe('

idle

'); + expect(fn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react/src/__tests__/useClientCapability-test.browser.tsx b/packages/react/src/__tests__/useClientCapability-test.browser.tsx new file mode 100644 index 000000000..5b8bf06f4 --- /dev/null +++ b/packages/react/src/__tests__/useClientCapability-test.browser.tsx @@ -0,0 +1,115 @@ +import { + createClient, + isSolanaError, + SOLANA_ERROR__REACT__MISSING_CAPABILITY, + SOLANA_ERROR__REACT__MISSING_PROVIDER, +} from '@solana/kit'; +import React from 'react'; + +import { renderHook } from '../__test-utils__/render'; +import { ClientProvider } from '../ClientProvider'; +import { useClient } from '../useClient'; +import { useClientCapability } from '../useClientCapability'; + +type ClientWithFoo = { foo: { hello(): string } }; + +function wrapperFor(client: ReturnType>) { + return ({ children }: { children: React.ReactNode }) => {children}; +} + +describe('useClientCapability', () => { + it('returns the client when the capability is present', () => { + const client = createClient({ foo: { hello: () => 'world' } }); + const { result } = renderHook( + () => + useClientCapability({ + capability: 'foo', + hookName: 'useFoo', + providerHint: 'Install fooPlugin().', + }), + { wrapper: wrapperFor(client) }, + ); + expect(result.current).toBe(client); + }); + + it('throws MISSING_CAPABILITY with hookName + providerHint when the capability is absent', () => { + const client = createClient(); // no `foo` capability + const { result } = renderHook( + () => { + try { + return useClientCapability({ + capability: 'foo', + hookName: 'useFoo', + providerHint: 'Install fooPlugin().', + }); + } catch (err) { + return err; + } + }, + { wrapper: wrapperFor(client) }, + ); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_CAPABILITY)).toBe(true); + const ctx = ( + result.current as { context: { capabilities: readonly string[]; hookName: string; providerHint: string } } + ).context; + expect(ctx.capabilities).toEqual(['foo']); + expect(ctx.hookName).toBe('useFoo'); + expect(ctx.providerHint).toBe('Install fooPlugin().'); + }); + + it('reports only the missing entries when capability is an array', () => { + const client = createClient<{ rpc: object }>({ rpc: {} }); // missing rpcSubscriptions only + const { result } = renderHook( + () => { + try { + return useClientCapability<{ rpc: object; rpcSubscriptions: object }>({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useLiveData', + providerHint: 'Install solanaRpcConnection().', + }); + } catch (err) { + return err; + } + }, + { wrapper: wrapperFor(client) }, + ); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_CAPABILITY)).toBe(true); + expect((result.current as { context: { capabilities: readonly string[] } }).context.capabilities).toEqual([ + 'rpcSubscriptions', + ]); + }); + + it('reports every missing entry when several capabilities are absent', () => { + const client = createClient<{ rpc: object }>({ rpc: {} }); // missing rpcSubscriptions and wallet + const { result } = renderHook( + () => { + try { + return useClientCapability<{ rpc: object; rpcSubscriptions: object; wallet: object }>({ + capability: ['rpc', 'rpcSubscriptions', 'wallet'], + hookName: 'useEverything', + providerHint: 'Install the missing plugins.', + }); + } catch (err) { + return err; + } + }, + { wrapper: wrapperFor(client) }, + ); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_CAPABILITY)).toBe(true); + expect((result.current as { context: { capabilities: readonly string[] } }).context.capabilities).toEqual([ + 'rpcSubscriptions', + 'wallet', + ]); + }); + + it('underlying useClient throws MISSING_PROVIDER outside a provider', () => { + const { result } = renderHook(() => { + try { + return useClient(); + } catch (err) { + return err; + } + }); + expect(isSolanaError(result.current, SOLANA_ERROR__REACT__MISSING_PROVIDER)).toBe(true); + }); +}); diff --git a/packages/react/src/__tests__/useRequest-test.browser.tsx b/packages/react/src/__tests__/useRequest-test.browser.tsx new file mode 100644 index 000000000..323964e60 --- /dev/null +++ b/packages/react/src/__tests__/useRequest-test.browser.tsx @@ -0,0 +1,355 @@ +import { createReactiveActionStore, ReactiveActionSource } from '@solana/kit'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { renderHook } from '../__test-utils__/render'; +import { useRequest } from '../useRequest'; + +function makeFakeRequest(): { + fn: jest.Mock, [AbortSignal]>; + rejectLatest: (err: unknown) => void; + resolveLatest: (value: T) => void; + source: ReactiveActionSource; +} { + let latest: PromiseWithResolvers | null = null; + const fn = jest.fn, [AbortSignal]>(() => { + latest = Promise.withResolvers(); + return latest.promise; + }); + return { + fn, + rejectLatest(err) { + latest!.reject(err); + }, + resolveLatest(value) { + latest!.resolve(value); + }, + source: { + reactiveStore() { + return createReactiveActionStore<[], T>(fn); + }, + }, + }; +} + +describe('useRequest', () => { + it('auto-dispatches on mount and transitions fetching → success', async () => { + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + expect(result.current.status).toBe('fetching'); + expect(result.current.data).toBeUndefined(); + expect(req.fn).toHaveBeenCalledTimes(1); + + await act(async () => req.resolveLatest('hi')); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe('hi'); + }); + + it('reports error status with the error value when the call rejects', async () => { + const boom = new Error('boom'); + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + await act(async () => req.rejectLatest(boom)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + }); + + it('refresh() after an error returns to fetching while preserving the stale error', async () => { + const boom = new Error('boom'); + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + await act(async () => req.rejectLatest(boom)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + expect(req.fn).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(req.fn).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe('fetching'); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBe(boom); + + await act(async () => req.resolveLatest('ok')); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe('ok'); + expect(result.current.error).toBeUndefined(); + }); + + it('refresh() re-dispatches and returns to fetching while preserving stale data', async () => { + const req = makeFakeRequest(); + const { result } = renderHook(() => useRequest(req.source)); + + await act(async () => req.resolveLatest('first')); + expect(result.current.data).toBe('first'); + expect(req.fn).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(req.fn).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe('fetching'); + expect(result.current.data).toBe('first'); + + await act(async () => req.resolveLatest('second')); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe('second'); + }); + + it('rebuilds the store and fires a fresh request when the source identity changes', () => { + const reqA = makeFakeRequest(); + const reqB = makeFakeRequest(); + const { rerender } = renderHook( + ({ which }: { which: 'a' | 'b' }) => useRequest(which === 'a' ? reqA.source : reqB.source), + { initialProps: { which: 'a' } }, + ); + expect(reqA.fn).toHaveBeenCalledTimes(1); + expect(reqB.fn).not.toHaveBeenCalled(); + + rerender({ which: 'b' }); + expect(reqB.fn).toHaveBeenCalledTimes(1); + }); + + it('reports status: disabled when the source is null', () => { + const { result } = renderHook(() => useRequest(null)); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('starts firing when the source transitions from null to a real source', () => { + const req = makeFakeRequest(); + const initialProps: { source: ReactiveActionSource | null } = { source: null }; + const { result, rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + expect(result.current.status).toBe('disabled'); + expect(req.fn).not.toHaveBeenCalled(); + + rerender({ source: req.source }); + expect(result.current.status).toBe('fetching'); + expect(req.fn).toHaveBeenCalledTimes(1); + }); + + it('returns to disabled when the source transitions from a real source to null', async () => { + const req = makeFakeRequest(); + const initialProps: { source: ReactiveActionSource | null } = { source: req.source }; + const { result, rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + await act(async () => req.resolveLatest('hi')); + expect(result.current.status).toBe('success'); + + rerender({ source: null }); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('aborts the in-flight request when the source transitions to null', () => { + const req = makeFakeRequest(); + const initialProps: { source: ReactiveActionSource | null } = { source: req.source }; + const { rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + const inFlightSignal = req.fn.mock.calls[0][0]; + expect(inFlightSignal.aborted).toBe(false); + + rerender({ source: null }); + expect(inFlightSignal.aborted).toBe(true); + }); + + it('aborts the in-flight request when the component unmounts', () => { + const req = makeFakeRequest(); + const { unmount } = renderHook(() => useRequest(req.source)); + const inFlightSignal = req.fn.mock.calls[0][0]; + expect(inFlightSignal.aborted).toBe(false); + + unmount(); + expect(inFlightSignal.aborted).toBe(true); + }); + + it('keeps a stable refresh reference across re-renders', () => { + const req = makeFakeRequest(); + const { result, rerender } = renderHook(() => useRequest(req.source)); + const { refresh } = result.current; + rerender(); + expect(result.current.refresh).toBe(refresh); + }); + + it('invokes getAbortSignal on every attempt with a fresh signal', () => { + const req = makeFakeRequest(); + const signals: AbortSignal[] = []; + const getAbortSignal = jest.fn(() => { + const ctrl = new AbortController(); + signals.push(ctrl.signal); + return ctrl.signal; + }); + const { result } = renderHook(() => useRequest(req.source, { getAbortSignal })); + + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(getAbortSignal).toHaveBeenCalledTimes(2); + expect(signals[1]).not.toBe(signals[0]); // fresh identity per attempt + }); + + it('refresh({ abortSignal }) overrides the getAbortSignal factory for that attempt', async () => { + const req = makeFakeRequest(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useRequest(req.source, { getAbortSignal })); + + // Initial fire uses the factory. + expect(getAbortSignal).toHaveBeenCalledTimes(1); + expect(req.fn).toHaveBeenCalledTimes(1); + + // Refresh with an override signal: factory is NOT called this time. + const overrideCtrl = new AbortController(); + act(() => result.current.refresh({ abortSignal: overrideCtrl.signal })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + expect(req.fn).toHaveBeenCalledTimes(2); + + // Aborting the override signal cancels the current attempt and surfaces on state. + const reason = new Error('overridden'); + await act(async () => overrideCtrl.abort(reason)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(reason); + }); + + it('refresh() without an abortSignal arg falls back to the getAbortSignal factory', () => { + const req = makeFakeRequest(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useRequest(req.source, { getAbortSignal })); + + expect(getAbortSignal).toHaveBeenCalledTimes(1); + act(() => result.current.refresh()); + expect(getAbortSignal).toHaveBeenCalledTimes(2); + }); + + it('refresh({ abortSignal: undefined }) opts out of the getAbortSignal factory for that attempt', () => { + const req = makeFakeRequest(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useRequest(req.source, { getAbortSignal })); + + // Initial fire uses the factory. + expect(getAbortSignal).toHaveBeenCalledTimes(1); + expect(req.fn).toHaveBeenCalledTimes(1); + // First-attempt signal is the factory's signal — composed via AbortSignal.any with the + // store's internal controller, so the fn receives a fresh non-aborted signal. + expect(req.fn.mock.calls[0][0].aborted).toBe(false); + + // Refresh with explicit undefined: factory is NOT invoked. + act(() => result.current.refresh({ abortSignal: undefined })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + expect(req.fn).toHaveBeenCalledTimes(2); + }); + + it('aborting the getAbortSignal transitions the current attempt to error; refresh starts a fresh one', async () => { + const req = makeFakeRequest(); + let currentCtrl: AbortController | undefined; + const getAbortSignal = () => { + currentCtrl = new AbortController(); + return currentCtrl.signal; + }; + const { result } = renderHook(() => useRequest(req.source, { getAbortSignal })); + + const timeoutReason = new Error('timeout'); + await act(async () => currentCtrl!.abort(timeoutReason)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(timeoutReason); + + act(() => result.current.refresh()); + expect(currentCtrl!.signal.aborted).toBe(false); // brand-new controller for the new attempt + + await act(async () => req.resolveLatest('recovered')); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe('recovered'); + }); + + describe('function shape', () => { + it('accepts a bare async function and fires it on mount', async () => { + const { promise, resolve } = Promise.withResolvers(); + const fn = jest.fn, [AbortSignal]>(() => promise); + const { result } = renderHook(() => useRequest(fn)); + expect(fn).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('fetching'); + + await act(async () => resolve('hi')); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe('hi'); + }); + + it('refresh() re-invokes the function with a fresh signal', async () => { + const { promise, resolve } = Promise.withResolvers(); + const fn = jest.fn, [AbortSignal]>(() => promise); + const { result } = renderHook(() => useRequest(fn)); + await act(async () => resolve('ok')); + expect(fn).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn.mock.calls[1][0]).not.toBe(fn.mock.calls[0][0]); + }); + + it('combines a per-request signal into the signal passed to the function', () => { + const fn = jest.fn, [AbortSignal]>(() => new Promise(() => {})); + const ctrl = new AbortController(); + renderHook(() => useRequest(fn, { getAbortSignal: () => ctrl.signal })); + + const innerSignal = fn.mock.calls[0][0]; + expect(innerSignal.aborted).toBe(false); + + ctrl.abort(new Error('timeout')); + expect(innerSignal.aborted).toBe(true); + }); + + it('rebuilds the store and fires a fresh request when one function source replaces another', () => { + const fnA = jest.fn, [AbortSignal]>(() => new Promise(() => {})); + const fnB = jest.fn, [AbortSignal]>(() => new Promise(() => {})); + const { rerender } = renderHook( + ({ fn }: { fn: (signal: AbortSignal) => Promise }) => useRequest(fn), + { + initialProps: { fn: fnA }, + }, + ); + expect(fnA).toHaveBeenCalledTimes(1); + expect(fnB).not.toHaveBeenCalled(); + + rerender({ fn: fnB }); + expect(fnA.mock.calls[0][0].aborted).toBe(true); // prior in-flight call aborted + expect(fnB).toHaveBeenCalledTimes(1); + }); + + it('rebuilds the store when switching between function and source shapes', () => { + const fn = jest.fn, [AbortSignal]>(() => new Promise(() => {})); + const req = makeFakeRequest(); + type Source = ReactiveActionSource | ((signal: AbortSignal) => Promise); + const initialProps: { source: Source } = { source: fn }; + const { rerender } = renderHook(({ source }) => useRequest(source), { initialProps }); + expect(fn).toHaveBeenCalledTimes(1); + expect(req.fn).not.toHaveBeenCalled(); + + rerender({ source: req.source }); + expect(fn.mock.calls[0][0].aborted).toBe(true); + expect(req.fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('SSR', () => { + it('renders `fetching` on the server without firing the request', () => { + const req = makeFakeRequest(); + function Component() { + const { status } = useRequest(req.source); + return

{status}

; + } + // `renderToString` drives `useSyncExternalStore` through its server-snapshot path + // (the third arg to `useSyncExternalStore`), and effects don't run during server + // rendering — so the store stays `idle` and the bridge maps that to `fetching`. + const html = renderToString(); + expect(html).toBe('

fetching

'); + expect(req.fn).not.toHaveBeenCalled(); + }); + + it('renders `disabled` on the server when the source is null', () => { + function Component() { + const { status } = useRequest(null); + return

{status}

; + } + const html = renderToString(); + expect(html).toBe('

disabled

'); + }); + }); +}); diff --git a/packages/react/src/__tests__/useSubscription-test.browser.tsx b/packages/react/src/__tests__/useSubscription-test.browser.tsx new file mode 100644 index 000000000..08544f4a8 --- /dev/null +++ b/packages/react/src/__tests__/useSubscription-test.browser.tsx @@ -0,0 +1,333 @@ +import { + createReactiveStoreFromDataPublisherFactory, + DataPublisher, + ReactiveStreamSource, + SolanaRpcResponse, +} from '@solana/kit'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { renderHook } from '../__test-utils__/render'; +import { useSubscription } from '../useSubscription'; + +type Notification = T | SolanaRpcResponse; + +function makeFakeSubscription(): { + publish: (notification: Notification) => Promise; + publishError: (err: unknown) => Promise; + publishersCreated: () => number; + source: ReactiveStreamSource; +} { + type Listener = (payload: unknown) => void; + let dataListeners: Listener[] = []; + let errorListeners: Listener[] = []; + let createdCount = 0; + let publisherReady = Promise.withResolvers(); + return { + async publish(notification) { + // Wait for the most recent connection's listeners to be wired up before firing + await publisherReady.promise; + dataListeners.forEach(fn => fn(notification)); + }, + async publishError(err) { + await publisherReady.promise; + errorListeners.forEach(fn => fn(err)); + }, + publishersCreated: () => createdCount, + source: { + reactiveStore() { + return createReactiveStoreFromDataPublisherFactory({ + createDataPublisher() { + createdCount++; + // Each connection gets a fresh publisher. Reset the listener arrays so + // any late callbacks from a torn-down prior connection can't reach the + // new one's listeners, and reset the ready handle so tests awaiting the + // *new* connection only proceed once it has wired up. + dataListeners = []; + errorListeners = []; + publisherReady = Promise.withResolvers(); + let onCallCount = 0; + const publisher: DataPublisher = { + on(channel, listener, options) { + const list = channel === 'data' ? dataListeners : errorListeners; + list.push(listener); + options?.signal.addEventListener( + 'abort', + () => { + const idx = list.indexOf(listener); + if (idx !== -1) list.splice(idx, 1); + }, + { once: true }, + ); + // The store binds both `data` and `error` channels on commit; + // after the second `.on` call the listeners are fully wired up + // and any awaiting publish/publishError calls can proceed. + if (++onCallCount === 2) publisherReady.resolve(); + return () => { + const idx = list.indexOf(listener); + if (idx !== -1) list.splice(idx, 1); + }; + }, + }; + return Promise.resolve(publisher); + }, + dataChannelName: 'data', + errorChannelName: 'error', + }); + }, + }, + }; +} + +describe('useSubscription', () => { + it('starts in loading, transitions to loaded on first notification', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const { result } = renderHook(() => useSubscription(sub.source)); + + expect(result.current.status).toBe('loading'); + expect(result.current.data).toBeUndefined(); + + await act(async () => sub.publish({ value: 42 })); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toStrictEqual({ value: 42 }); + }); + + it('surfaces `SolanaRpcResponse` envelopes as-is — callers read `.value` and `.context.slot`', async () => { + const sub = makeFakeSubscription>(); + const { result } = renderHook(() => useSubscription(sub.source)); + + await act(async () => sub.publish({ context: { slot: 99n }, value: { lamports: 5n } })); + expect(result.current.data).toStrictEqual({ context: { slot: 99n }, value: { lamports: 5n } }); + }); + + it('passes raw notifications through unchanged', async () => { + const sub = makeFakeSubscription<{ slot: bigint; parent: bigint; root: bigint }>(); + const { result } = renderHook(() => useSubscription(sub.source)); + + await act(async () => sub.publish({ parent: 9n, root: 8n, slot: 10n })); + expect(result.current.data).toStrictEqual({ parent: 9n, root: 8n, slot: 10n }); + }); + + it('transitions to error on error-channel publish, preserving stale data', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const { result } = renderHook(() => useSubscription(sub.source)); + await act(async () => sub.publish({ value: 1 })); + expect(result.current.data).toStrictEqual({ value: 1 }); + + const boom = new Error('boom'); + await act(async () => sub.publishError(boom)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + expect(result.current.data).toStrictEqual({ value: 1 }); // stale preserved + }); + + it('reconnect() re-opens the connection and transitions loading → loaded (SWR for data + error)', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const { result } = renderHook(() => useSubscription(sub.source)); + await act(async () => sub.publish({ value: 1 })); + const boom = new Error('fail'); + await act(async () => sub.publishError(boom)); + expect(result.current.status).toBe('error'); + + act(() => result.current.reconnect()); + expect(result.current.status).toBe('loading'); + expect(result.current.data).toStrictEqual({ value: 1 }); // stale data preserved + expect(result.current.error).toBe(boom); // stale error preserved (SWR) + + await act(async () => sub.publish({ value: 2 })); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toStrictEqual({ value: 2 }); + expect(result.current.error).toBeUndefined(); // cleared on successful reconnect + expect(sub.publishersCreated()).toBe(2); + }); + + it('reports status: disabled when the source is null', () => { + const { result } = renderHook(() => useSubscription<{ value: number }>(null)); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('starts connecting when the source transitions from null to a real source', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const initialProps: { source: ReactiveStreamSource<{ value: number }> | null } = { source: null }; + const { result, rerender } = renderHook(({ source }) => useSubscription(source), { initialProps }); + expect(result.current.status).toBe('disabled'); + expect(sub.publishersCreated()).toBe(0); + + rerender({ source: sub.source }); + expect(result.current.status).toBe('loading'); + await act(async () => sub.publish({ value: 1 })); + expect(result.current.status).toBe('loaded'); + expect(sub.publishersCreated()).toBe(1); + }); + + it('returns to disabled when the source transitions from a real source to null', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const initialProps: { source: ReactiveStreamSource<{ value: number }> | null } = { source: sub.source }; + const { result, rerender } = renderHook(({ source }) => useSubscription(source), { initialProps }); + await act(async () => sub.publish({ value: 1 })); + expect(result.current.status).toBe('loaded'); + + rerender({ source: null }); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('opens a fresh subscription when the source identity changes', async () => { + const subA = makeFakeSubscription<{ value: number }>(); + const subB = makeFakeSubscription<{ value: number }>(); + const { result, rerender } = renderHook( + ({ which }: { which: 'a' | 'b' }) => useSubscription(which === 'a' ? subA.source : subB.source), + { initialProps: { which: 'a' } }, + ); + await act(async () => subA.publish({ value: 1 })); + expect(result.current.data).toStrictEqual({ value: 1 }); + + rerender({ which: 'b' }); + await act(async () => subB.publish({ value: 2 })); + expect(result.current.data).toStrictEqual({ value: 2 }); + }); + + it("aborts the prior connection's listeners when the source identity changes", async () => { + const subA = makeFakeSubscription<{ value: number }>(); + const subB = makeFakeSubscription<{ value: number }>(); + const { result, rerender } = renderHook( + ({ which }: { which: 'a' | 'b' }) => useSubscription(which === 'a' ? subA.source : subB.source), + { initialProps: { which: 'a' } }, + ); + await act(async () => subA.publish({ value: 1 })); + + rerender({ which: 'b' }); + // Late publishes from the now-torn-down prior connection must not reach the hook. + await act(async () => subA.publish({ value: 99 })); + expect(result.current.data).not.toStrictEqual({ value: 99 }); + + await act(async () => subB.publish({ value: 2 })); + expect(result.current.data).toStrictEqual({ value: 2 }); + }); + + it('keeps a stable reconnect reference across re-renders', () => { + const sub = makeFakeSubscription<{ value: number }>(); + const { result, rerender } = renderHook(() => useSubscription(sub.source)); + const { reconnect } = result.current; + rerender(); + expect(result.current.reconnect).toBe(reconnect); + }); + + it('invokes `getAbortSignal` on every connection with a fresh signal', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const signals: AbortSignal[] = []; + const getAbortSignal = jest.fn(() => { + const ctrl = new AbortController(); + signals.push(ctrl.signal); + return ctrl.signal; + }); + const { result } = renderHook(() => useSubscription(sub.source, { getAbortSignal })); + await act(async () => sub.publish({ value: 1 })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + await act(async () => sub.publishError(new Error('fail'))); + act(() => result.current.reconnect()); + await act(async () => sub.publish({ value: 2 })); + expect(getAbortSignal).toHaveBeenCalledTimes(2); + expect(signals[1]).not.toBe(signals[0]); + }); + + it('reconnect({ abortSignal }) overrides the getAbortSignal factory for that attempt', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useSubscription(sub.source, { getAbortSignal })); + await act(async () => sub.publish({ value: 1 })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + const overrideCtrl = new AbortController(); + act(() => result.current.reconnect({ abortSignal: overrideCtrl.signal })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); // factory NOT called + expect(sub.publishersCreated()).toBe(2); + + await act(async () => overrideCtrl.abort(new Error('overridden'))); + expect(result.current.status).toBe('error'); + }); + + it('reconnect({ abortSignal: undefined }) opts out of the factory for that attempt', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useSubscription(sub.source, { getAbortSignal })); + await act(async () => sub.publish({ value: 1 })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + act(() => result.current.reconnect({ abortSignal: undefined })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); // factory NOT called + expect(sub.publishersCreated()).toBe(2); + }); + + it('reconnect() without an arg falls back to the getAbortSignal factory', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useSubscription(sub.source, { getAbortSignal })); + await act(async () => sub.publish({ value: 1 })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + act(() => result.current.reconnect()); + expect(getAbortSignal).toHaveBeenCalledTimes(2); + }); + + it('aborting the getAbortSignal transitions the current connection to error; reconnect recovers', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + let currentCtrl: AbortController | undefined; + const getAbortSignal = () => { + currentCtrl = new AbortController(); + return currentCtrl.signal; + }; + const { result } = renderHook(() => useSubscription(sub.source, { getAbortSignal })); + + const timeoutReason = new Error('timeout'); + await act(async () => currentCtrl!.abort(timeoutReason)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(timeoutReason); + + act(() => result.current.reconnect()); + expect(currentCtrl!.signal.aborted).toBe(false); // brand-new controller for the new connection + + await act(async () => sub.publish({ value: 1 })); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toStrictEqual({ value: 1 }); + }); + + it('aborts the in-flight subscription when the component unmounts', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const { unmount } = renderHook(() => useSubscription(sub.source)); + await act(async () => sub.publish({ value: 1 })); + expect(sub.publishersCreated()).toBe(1); + unmount(); + // After unmount, late publishes don't drive listeners — they've been removed via reset(). + await sub.publish({ value: 2 }); + // No assertion needed beyond "no throw"; we're verifying reset() ran without error. + }); + + describe('SSR', () => { + it('renders `loading` on the server without opening a subscription', () => { + const sub = makeFakeSubscription<{ value: number }>(); + function Component() { + const { status } = useSubscription(sub.source); + return

{status}

; + } + // `renderToString` drives `useSyncExternalStore` through its server-snapshot path + // (the third arg to `useSyncExternalStore`), and effects don't run during server + // rendering — so the store stays `idle` and the bridge maps that to `loading`. + const html = renderToString(); + expect(html).toBe('

loading

'); + expect(sub.publishersCreated()).toBe(0); + }); + + it('renders `disabled` on the server when the source is null', () => { + function Component() { + const { status } = useSubscription<{ value: number }>(null); + return

{status}

; + } + const html = renderToString(); + expect(html).toBe('

disabled

'); + }); + }); +}); diff --git a/packages/react/src/__tests__/useTrackedData-test.browser.tsx b/packages/react/src/__tests__/useTrackedData-test.browser.tsx new file mode 100644 index 000000000..3909ec5f0 --- /dev/null +++ b/packages/react/src/__tests__/useTrackedData-test.browser.tsx @@ -0,0 +1,385 @@ +import type { RpcSendable, RpcSubscribable, SolanaRpcResponse } from '@solana/kit'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { renderHook } from '../__test-utils__/render'; +import { TrackedDataSpec, useTrackedData } from '../useTrackedData'; + +type TestValue = { count: number }; + +function rpcResponse(slot: number, value: TestValue): SolanaRpcResponse { + return { context: { slot: BigInt(slot) }, value }; +} + +function createMockRpcRequest(): { + mockRequest: RpcSendable>; + reject(error: unknown): void; + resolve(response: SolanaRpcResponse): void; +} { + const { promise, resolve, reject } = Promise.withResolvers>(); + return { + mockRequest: { + send: jest.fn().mockReturnValue(promise), + }, + reject, + resolve, + }; +} + +function createMockSubscriptionRequest(): { + error(err: unknown): void; + mockRequest: RpcSubscribable>; + pushNotification(notification: SolanaRpcResponse): void; +} { + const notifications: SolanaRpcResponse[] = []; + let waitingResolve: ((value: IteratorResult>) => void) | null = null; + let waitingReject: ((reason: unknown) => void) | null = null; + let errorValue: unknown; + let hasError = false; + + const asyncIterable: AsyncIterable> = { + [Symbol.asyncIterator]() { + return { + next() { + if (notifications.length > 0) { + return Promise.resolve({ done: false, value: notifications.shift()! } as const); + } + if (hasError) { + return Promise.reject(errorValue as Error); + } + return new Promise>>((resolve, reject) => { + waitingResolve = resolve; + waitingReject = reject; + }); + }, + }; + }, + }; + + return { + error(err) { + hasError = true; + errorValue = err; + if (waitingReject) { + const reject = waitingReject; + waitingResolve = null; + waitingReject = null; + reject(err); + } + }, + mockRequest: { + subscribe: jest.fn().mockResolvedValue(asyncIterable), + }, + pushNotification(notification) { + if (waitingResolve) { + const resolve = waitingResolve; + waitingResolve = null; + resolve({ done: false, value: notification }); + } else { + notifications.push(notification); + } + }, + }; +} + +type Spec = TrackedDataSpec; +function makeSpec(): { + error: (err: unknown) => void; + pushNotification: (notification: SolanaRpcResponse) => void; + rejectRpc: (error: unknown) => void; + resolveRpc: (response: SolanaRpcResponse) => void; + rpcSendCalls: () => number; + spec: Spec; + subscribeCalls: () => number; +} { + const rpc = createMockRpcRequest(); + const sub = createMockSubscriptionRequest(); + return { + error: sub.error, + pushNotification: sub.pushNotification, + rejectRpc: rpc.reject, + resolveRpc: rpc.resolve, + rpcSendCalls: () => (rpc.mockRequest.send as jest.Mock).mock.calls.length, + spec: { + rpcRequest: rpc.mockRequest, + rpcSubscriptionRequest: sub.mockRequest, + rpcSubscriptionValueMapper: v => v.count, + rpcValueMapper: v => v.count, + }, + subscribeCalls: () => (sub.mockRequest.subscribe as jest.Mock).mock.calls.length, + }; +} + +describe('useTrackedData', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('starts in loading, transitions to loaded with the initial RPC value', async () => { + const { spec, resolveRpc } = makeSpec(); + const { result } = renderHook(() => useTrackedData(spec)); + + expect(result.current.status).toBe('loading'); + expect(result.current.data).toBeUndefined(); + + await act(async () => { + resolveRpc(rpcResponse(100, { count: 42 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 42 }); + }); + + it('promotes a subscription notification over the initial RPC when the slot is newer', async () => { + const { spec, resolveRpc, pushNotification } = makeSpec(); + const { result } = renderHook(() => useTrackedData(spec)); + await act(async () => { + resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 }); + + await act(async () => { + pushNotification(rpcResponse(200, { count: 2 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 200n }, value: 2 }); + }); + + it('drops a stale subscription notification with a slot older than the current value', async () => { + const { spec, resolveRpc, pushNotification } = makeSpec(); + const { result } = renderHook(() => useTrackedData(spec)); + await act(async () => { + resolveRpc(rpcResponse(200, { count: 99 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 200n }, value: 99 }); + + // Older slot — store ignores; UI keeps the newer value. + await act(async () => { + pushNotification(rpcResponse(150, { count: 7 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 200n }, value: 99 }); + }); + + it('drops the initial RPC value when a newer subscription notification arrived first', async () => { + const { spec, resolveRpc, pushNotification } = makeSpec(); + const { result } = renderHook(() => useTrackedData(spec)); + // Subscription arrives first at a newer slot. + await act(async () => { + pushNotification(rpcResponse(300, { count: 5 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 300n }, value: 5 }); + + // Then the initial RPC resolves with an older slot — must NOT regress the value. + await act(async () => { + resolveRpc(rpcResponse(200, { count: 99 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 300n }, value: 5 }); + }); + + it('transitions to error when the initial RPC rejects, preserving stale data if any', async () => { + const { spec, rejectRpc, pushNotification } = makeSpec(); + const { result } = renderHook(() => useTrackedData(spec)); + // Subscription delivers a value first. + await act(async () => { + pushNotification(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + const boom = new Error('boom'); + await act(async () => { + rejectRpc(boom); + await jest.runAllTimersAsync(); + }); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 }); // stale preserved + }); + + it('refresh() re-runs the pair and returns to loading with stale data preserved', async () => { + const { spec, resolveRpc, pushNotification, rpcSendCalls, subscribeCalls } = makeSpec(); + const { result } = renderHook(() => useTrackedData(spec)); + await act(async () => { + resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + await act(async () => { + pushNotification(rpcResponse(150, { count: 2 })); + await jest.runAllTimersAsync(); + }); + expect(rpcSendCalls()).toBe(1); + expect(subscribeCalls()).toBe(1); + + act(() => result.current.refresh()); + // Both sources re-fire. + expect(rpcSendCalls()).toBe(2); + expect(subscribeCalls()).toBe(2); + // Status returns to loading with stale data preserved. + expect(result.current.status).toBe('loading'); + expect(result.current.data).toStrictEqual({ context: { slot: 150n }, value: 2 }); + }); + + it('reports status: disabled when the spec is null', () => { + const { result } = renderHook(() => useTrackedData(null)); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('starts running when the spec transitions from null to a real one', async () => { + const fake = makeSpec(); + const initialProps: { spec: Spec | null } = { spec: null }; + const { result, rerender } = renderHook(({ spec }) => useTrackedData(spec), { initialProps }); + expect(result.current.status).toBe('disabled'); + expect(fake.rpcSendCalls()).toBe(0); + + rerender({ spec: fake.spec }); + expect(result.current.status).toBe('loading'); + await act(async () => { + fake.resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.status).toBe('loaded'); + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 }); + }); + + it('returns to disabled when the spec transitions to null', async () => { + const fake = makeSpec(); + const initialProps: { spec: Spec | null } = { spec: fake.spec }; + const { result, rerender } = renderHook(({ spec }) => useTrackedData(spec), { initialProps }); + await act(async () => { + fake.resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.status).toBe('loaded'); + + rerender({ spec: null }); + expect(result.current.status).toBe('disabled'); + expect(result.current.data).toBeUndefined(); + }); + + it('rebuilds the store when the spec identity changes', async () => { + const a = makeSpec(); + const b = makeSpec(); + const { result, rerender } = renderHook(({ spec }: { spec: Spec }) => useTrackedData(spec), { + initialProps: { spec: a.spec }, + }); + await act(async () => { + a.resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 }); + + rerender({ spec: b.spec }); + expect(result.current.status).toBe('loading'); + await act(async () => { + b.resolveRpc(rpcResponse(50, { count: 2 })); + await jest.runAllTimersAsync(); + }); + // Fresh store → slot tracking resets, so 50 is accepted as the new baseline. + expect(result.current.data).toStrictEqual({ context: { slot: 50n }, value: 2 }); + }); + + it('keeps a stable refresh reference across re-renders', () => { + const { spec } = makeSpec(); + const { result, rerender } = renderHook(() => useTrackedData(spec)); + const { refresh } = result.current; + rerender(); + expect(result.current.refresh).toBe(refresh); + }); + + it('invokes `getAbortSignal` on every attempt with a fresh signal', async () => { + const fake = makeSpec(); + const signals: AbortSignal[] = []; + const getAbortSignal = jest.fn(() => { + const ctrl = new AbortController(); + signals.push(ctrl.signal); + return ctrl.signal; + }); + const { result } = renderHook(() => useTrackedData(fake.spec, { getAbortSignal })); + await act(async () => { + fake.resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh()); + expect(getAbortSignal).toHaveBeenCalledTimes(2); + expect(signals[1]).not.toBe(signals[0]); + }); + + it('refresh({ abortSignal }) overrides the factory for that attempt', async () => { + const fake = makeSpec(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useTrackedData(fake.spec, { getAbortSignal })); + await act(async () => { + fake.resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + const overrideCtrl = new AbortController(); + act(() => result.current.refresh({ abortSignal: overrideCtrl.signal })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); // factory NOT called + + await act(async () => { + overrideCtrl.abort(new Error('overridden')); + await jest.runAllTimersAsync(); + }); + expect(result.current.status).toBe('error'); + }); + + it('refresh({ abortSignal: undefined }) opts out of the factory for that attempt', async () => { + const fake = makeSpec(); + const getAbortSignal = jest.fn(() => new AbortController().signal); + const { result } = renderHook(() => useTrackedData(fake.spec, { getAbortSignal })); + await act(async () => { + fake.resolveRpc(rpcResponse(100, { count: 1 })); + await jest.runAllTimersAsync(); + }); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + + act(() => result.current.refresh({ abortSignal: undefined })); + expect(getAbortSignal).toHaveBeenCalledTimes(1); // factory NOT called + expect(fake.rpcSendCalls()).toBe(2); + }); + + it('aborts the in-flight attempt when the component unmounts', () => { + const { spec, rpcSendCalls } = makeSpec(); + const { unmount } = renderHook(() => useTrackedData(spec)); + expect(rpcSendCalls()).toBe(1); + const abortSignal = (spec.rpcRequest.send as jest.Mock).mock.calls[0][0].abortSignal as AbortSignal; + expect(abortSignal.aborted).toBe(false); + unmount(); + expect(abortSignal.aborted).toBe(true); + }); + + describe('SSR', () => { + it('renders `loading` on the server without firing the RPC or subscription', () => { + const fake = makeSpec(); + function Component() { + const { status } = useTrackedData(fake.spec); + return

{status}

; + } + const html = renderToString(); + expect(html).toBe('

loading

'); + expect(fake.rpcSendCalls()).toBe(0); + expect(fake.subscribeCalls()).toBe(0); + }); + + it('renders `disabled` on the server when the spec is null', () => { + function Component() { + const { status } = useTrackedData(null); + return

{status}

; + } + const html = renderToString(); + expect(html).toBe('

disabled

'); + }); + }); +}); diff --git a/packages/react/src/__tests__/useWalletAccountMessageSigner-test.ts b/packages/react/src/__tests__/useWalletAccountMessageSigner-test.ts index cc746324e..1f5ff21e1 100644 --- a/packages/react/src/__tests__/useWalletAccountMessageSigner-test.ts +++ b/packages/react/src/__tests__/useWalletAccountMessageSigner-test.ts @@ -1,5 +1,4 @@ -import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors'; -import { SignatureBytes } from '@solana/keys'; +import { SignatureBytes, SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/kit'; import type { UiWalletAccount } from '@wallet-standard/ui'; import { renderHook } from '../test-renderer'; diff --git a/packages/react/src/__tests__/useWalletAccountTransactionSendingSigner-test.ts b/packages/react/src/__tests__/useWalletAccountTransactionSendingSigner-test.ts index 6c7400b12..9d9bf94d9 100644 --- a/packages/react/src/__tests__/useWalletAccountTransactionSendingSigner-test.ts +++ b/packages/react/src/__tests__/useWalletAccountTransactionSendingSigner-test.ts @@ -1,16 +1,23 @@ -import { Address } from '@solana/addresses'; -import type { VariableSizeEncoder } from '@solana/codecs-core'; -import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors'; -import { SignatureBytes } from '@solana/keys'; -import { Transaction, TransactionMessageBytes } from '@solana/transactions'; -import { getTransactionEncoder } from '@solana/transactions'; +import { + Address, + getTransactionEncoder, + SignatureBytes, + SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, + SolanaError, + Transaction, + TransactionMessageBytes, + type VariableSizeEncoder, +} from '@solana/kit'; import type { UiWalletAccount } from '@wallet-standard/ui'; import { renderHook } from '../test-renderer'; import { useSignAndSendTransaction } from '../useSignAndSendTransaction'; import { useWalletAccountTransactionSendingSigner } from '../useWalletAccountTransactionSendingSigner'; -jest.mock('@solana/transactions'); +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + getTransactionEncoder: jest.fn(), +})); jest.mock('../useSignAndSendTransaction'); describe('useWalletAccountTransactionSendingSigner', () => { diff --git a/packages/react/src/__tests__/useWalletAccountTransactionSigner-test.ts b/packages/react/src/__tests__/useWalletAccountTransactionSigner-test.ts index b6d63d305..bb8a85910 100644 --- a/packages/react/src/__tests__/useWalletAccountTransactionSigner-test.ts +++ b/packages/react/src/__tests__/useWalletAccountTransactionSigner-test.ts @@ -1,31 +1,33 @@ -import { Address } from '@solana/addresses'; -import type { VariableSizeCodec, VariableSizeDecoder } from '@solana/codecs-core'; -import { - SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, - SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, - SolanaError, -} from '@solana/errors'; -import { SignatureBytes } from '@solana/keys'; -import { Blockhash } from '@solana/rpc-types'; import { + Address, + Blockhash, CompiledTransactionMessage, CompiledTransactionMessageWithLifetime, getCompiledTransactionMessageDecoder, -} from '@solana/transaction-messages'; -import { + getTransactionCodec, getTransactionLifetimeConstraintFromCompiledTransactionMessage, + SignatureBytes, + SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, + SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, + SolanaError, Transaction, TransactionMessageBytes, -} from '@solana/transactions'; -import { getTransactionCodec } from '@solana/transactions'; + type VariableSizeCodec, + type VariableSizeDecoder, +} from '@solana/kit'; import type { UiWalletAccount } from '@wallet-standard/ui'; import { renderHook } from '../test-renderer'; import { useSignTransaction } from '../useSignTransaction'; import { useWalletAccountTransactionSigner } from '../useWalletAccountTransactionSigner'; -jest.mock('@solana/transaction-messages'); -jest.mock('@solana/transactions'); +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + assertIsTransactionWithinSizeLimit: jest.fn(), + getCompiledTransactionMessageDecoder: jest.fn(), + getTransactionCodec: jest.fn(), + getTransactionLifetimeConstraintFromCompiledTransactionMessage: jest.fn(), +})); jest.mock('../useSignTransaction'); describe('useWalletAccountTransactionSigner', () => { diff --git a/packages/react/src/__typetests__/useAction-typetest.ts b/packages/react/src/__typetests__/useAction-typetest.ts new file mode 100644 index 000000000..577010fcc --- /dev/null +++ b/packages/react/src/__typetests__/useAction-typetest.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { ActionResult, useAction } from '../useAction'; + +// [DESCRIBE] useAction +{ + // It infers TArgs and TResult from the wrapped function + { + const result = useAction(async (_signal: AbortSignal, value: number) => `n=${value}`); + result satisfies ActionResult<[value: number], string>; + result.dispatch(7) satisfies Promise; + result.data satisfies string | undefined; + } + + // The status field is a discriminated string union, not a generic string + { + const fn = (): Promise => Promise.resolve(1); + const { status } = useAction(fn); + status satisfies 'error' | 'idle' | 'running' | 'success'; + // @ts-expect-error - 'pending' is not a valid status + status satisfies 'pending'; + } + + // dispatch rejects calls that pass the wrong argument types + { + const { dispatch } = useAction(async (_signal: AbortSignal, _value: number) => 0); + dispatch(1); + // @ts-expect-error - argument should be a number + dispatch('not a number'); + } + + // Zero-argument actions get a zero-argument dispatch + { + const fn = (): Promise => Promise.resolve('ok'); + const { dispatch } = useAction(fn); + dispatch() satisfies Promise; + // @ts-expect-error - dispatch takes no arguments + dispatch('extra'); + } +} diff --git a/packages/react/src/__typetests__/useClient-typetest.ts b/packages/react/src/__typetests__/useClient-typetest.ts new file mode 100644 index 000000000..1aab3fec9 --- /dev/null +++ b/packages/react/src/__typetests__/useClient-typetest.ts @@ -0,0 +1,33 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { Client } from '@solana/kit'; + +import { useClient } from '../useClient'; + +type ClientWithFoo = { foo: { hello(): string } }; + +// [DESCRIBE] useClient +{ + // It defaults to `Client` + { + const client = useClient(); + client satisfies Client; + // @ts-expect-error - the base shape carries no plugin capabilities + void client.foo; + } + + // It narrows to the requested shape via the generic + { + const client = useClient(); + client satisfies Client; + client.foo.hello() satisfies string; + // @ts-expect-error - capability not declared in the generic + void client.bar; + } + + // The narrowed client retains the `use` method from `Client` + { + const client = useClient(); + client.use satisfies Client['use']; + } +} diff --git a/packages/react/src/__typetests__/useClientCapability-typetest.ts b/packages/react/src/__typetests__/useClientCapability-typetest.ts new file mode 100644 index 000000000..98d8f6b2b --- /dev/null +++ b/packages/react/src/__typetests__/useClientCapability-typetest.ts @@ -0,0 +1,50 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { Client } from '@solana/kit'; + +import { useClientCapability } from '../useClientCapability'; + +type ClientWithRpc = { rpc: { getEpoch(): bigint } }; +type ClientWithRpcAndSubs = ClientWithRpc & { rpcSubscriptions: { slotChanges(): void } }; + +// [DESCRIBE] useClientCapability +{ + // It narrows the return type via the generic + { + const client = useClientCapability({ + capability: 'rpc', + hookName: 'useRpc', + providerHint: 'Install rpcPlugin().', + }); + client satisfies Client; + client.rpc.getEpoch() satisfies bigint; + // @ts-expect-error - capability not declared in the generic + void client.rpcSubscriptions; + } + + // The `capability` config field accepts a single name + { + useClientCapability({ + capability: 'rpc', + hookName: 'useRpc', + providerHint: 'Install rpcPlugin().', + }); + } + + // The `capability` config field accepts an array of names + { + useClientCapability({ + capability: ['rpc', 'rpcSubscriptions'], + hookName: 'useLiveData', + providerHint: 'Install solanaRpcConnection().', + }); + } + + // It rejects configs missing required fields + { + useClientCapability( + // @ts-expect-error - missing hookName + providerHint + { capability: 'rpc' }, + ); + } +} diff --git a/packages/react/src/__typetests__/useRequest-typetest.ts b/packages/react/src/__typetests__/useRequest-typetest.ts new file mode 100644 index 000000000..10922f188 --- /dev/null +++ b/packages/react/src/__typetests__/useRequest-typetest.ts @@ -0,0 +1,28 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { ReactiveActionSource } from '@solana/kit'; + +import { RequestResult, useRequest } from '../useRequest'; + +const slotSource = null as unknown as ReactiveActionSource<{ slot: bigint }>; + +// [DESCRIBE] useRequest +{ + // Infers T from the source + useRequest(slotSource) satisfies RequestResult<{ slot: bigint }>; + + // The source argument accepts null + useRequest<{ slot: bigint }>(null) satisfies RequestResult<{ slot: bigint }>; + + // Options accept a `getAbortSignal` factory + useRequest(slotSource, { getAbortSignal: () => AbortSignal.timeout(5_000) }); + + // `refresh` accepts no args (uses the factory), an `{ abortSignal }` override, or + // `{ abortSignal: undefined }` to opt out of the factory entirely. + const { refresh } = useRequest(slotSource); + refresh(); + refresh({ abortSignal: AbortSignal.timeout(1_000) }); + refresh({ abortSignal: undefined }); + // @ts-expect-error - abortSignal must be an AbortSignal (or undefined) + refresh({ abortSignal: 'nope' }); +} diff --git a/packages/react/src/__typetests__/useSignAndSendTransaction-typetest.ts b/packages/react/src/__typetests__/useSignAndSendTransaction-typetest.ts index 0ad64d3f7..c5ca5f9c6 100644 --- a/packages/react/src/__typetests__/useSignAndSendTransaction-typetest.ts +++ b/packages/react/src/__typetests__/useSignAndSendTransaction-typetest.ts @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { address } from '@solana/addresses'; +import { address } from '@solana/kit'; import { UiWalletAccount } from '@wallet-standard/ui'; import { useSignAndSendTransaction, useSignAndSendTransactions } from '../useSignAndSendTransaction'; diff --git a/packages/react/src/__typetests__/useSignIn-typetest.ts b/packages/react/src/__typetests__/useSignIn-typetest.ts index 03078ed03..7a7421866 100644 --- a/packages/react/src/__typetests__/useSignIn-typetest.ts +++ b/packages/react/src/__typetests__/useSignIn-typetest.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ /* eslint-disable react-hooks/rules-of-hooks */ -import { address } from '@solana/addresses'; +import { address } from '@solana/kit'; import { WalletVersion } from '@wallet-standard/base'; import { UiWalletAccount } from '@wallet-standard/ui'; diff --git a/packages/react/src/__typetests__/useSignTransaction-typetest.ts b/packages/react/src/__typetests__/useSignTransaction-typetest.ts index c115a009c..aac6385e0 100644 --- a/packages/react/src/__typetests__/useSignTransaction-typetest.ts +++ b/packages/react/src/__typetests__/useSignTransaction-typetest.ts @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { address } from '@solana/addresses'; +import { address } from '@solana/kit'; import { UiWalletAccount } from '@wallet-standard/ui'; import { useSignTransaction, useSignTransactions } from '../useSignTransaction'; diff --git a/packages/react/src/__typetests__/useSubscription-typetest.ts b/packages/react/src/__typetests__/useSubscription-typetest.ts new file mode 100644 index 000000000..ee4947188 --- /dev/null +++ b/packages/react/src/__typetests__/useSubscription-typetest.ts @@ -0,0 +1,42 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { ReactiveStreamSource, SolanaRpcResponse } from '@solana/kit'; + +import { SubscriptionResult, useSubscription } from '../useSubscription'; + +const accountSource = null as unknown as ReactiveStreamSource>; +const slotSource = null as unknown as ReactiveStreamSource<{ slot: bigint }>; + +// [DESCRIBE] useSubscription +{ + // Envelope sources surface the envelope as-is — callers read `data.value` and + // `data.context.slot` directly. + const account = useSubscription(accountSource); + account satisfies SubscriptionResult>; + account.data satisfies SolanaRpcResponse<{ lamports: bigint }> | undefined; + account.data?.value satisfies { lamports: bigint } | undefined; + account.data?.context.slot satisfies bigint | undefined; + + // Raw notifications pass through unchanged. + useSubscription(slotSource) satisfies SubscriptionResult<{ slot: bigint }>; + + // Source argument accepts null + useSubscription<{ slot: bigint }>(null) satisfies SubscriptionResult<{ slot: bigint }>; + + // Options accept a `getAbortSignal` factory + useSubscription(slotSource, { getAbortSignal: () => AbortSignal.timeout(30_000) }); + + // `reconnect` accepts no args (uses the factory), an `{ abortSignal }` override, or + // `{ abortSignal: undefined }` to opt out of the factory entirely. + const { reconnect, status } = useSubscription(slotSource); + reconnect(); + reconnect({ abortSignal: AbortSignal.timeout(1_000) }); + reconnect({ abortSignal: undefined }); + // @ts-expect-error - abortSignal must be an AbortSignal (or undefined) + reconnect({ abortSignal: 'nope' }); + + // Status is a discriminated string, not a generic string + status satisfies 'disabled' | 'error' | 'loaded' | 'loading'; + // @ts-expect-error - 'success' is the action-store vocabulary, not stream + status satisfies 'success'; +} diff --git a/packages/react/src/__typetests__/useTrackedData-typetest.ts b/packages/react/src/__typetests__/useTrackedData-typetest.ts new file mode 100644 index 000000000..22dd8183d --- /dev/null +++ b/packages/react/src/__typetests__/useTrackedData-typetest.ts @@ -0,0 +1,53 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { CreateReactiveStoreWithInitialValueAndSlotTrackingConfig, Slot, SolanaRpcResponse } from '@solana/kit'; + +import { TrackedDataResult, TrackedDataSpec, useTrackedData } from '../useTrackedData'; + +const spec = null as unknown as TrackedDataSpec<{ a: number }, { b: number }, number>; + +// [DESCRIBE] TrackedDataSpec +{ + // `TrackedDataSpec` is the React-local alias of the Kit primitive's config — same shape. + null as unknown as TrackedDataSpec< + { a: number }, + { b: number }, + number + > satisfies CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<{ a: number }, { b: number }, number>; + null as unknown as CreateReactiveStoreWithInitialValueAndSlotTrackingConfig< + { a: number }, + { b: number }, + number + > satisfies TrackedDataSpec<{ a: number }, { b: number }, number>; +} + +// [DESCRIBE] useTrackedData +{ + // Infers `TItem` from the spec's mappers and surfaces `data` as the primitive's guaranteed + // `SolanaRpcResponse` envelope. + const result = useTrackedData(spec); + result satisfies TrackedDataResult; + result.data satisfies SolanaRpcResponse | undefined; + result.data?.value satisfies number | undefined; + result.data?.context.slot satisfies Slot | undefined; + + // Spec accepts null (disabled) + useTrackedData<{ a: number }, { b: number }, number>(null) satisfies TrackedDataResult; + + // Options accept a `getAbortSignal` factory + useTrackedData(spec, { getAbortSignal: () => AbortSignal.timeout(30_000) }); + + // `refresh` accepts no args (uses factory), an `{ abortSignal }` override, or + // `{ abortSignal: undefined }` to opt out of the factory entirely. + const { refresh, status } = useTrackedData(spec); + refresh(); + refresh({ abortSignal: AbortSignal.timeout(1_000) }); + refresh({ abortSignal: undefined }); + // @ts-expect-error - abortSignal must be an AbortSignal (or undefined) + refresh({ abortSignal: 'nope' }); + + // Status is a discriminated string literal + status satisfies 'disabled' | 'error' | 'loaded' | 'loading'; + // @ts-expect-error - 'success' is the action-store vocabulary, not stream + status satisfies 'success'; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1125a2f7a..635cad243 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,10 +3,17 @@ * * @packageDocumentation */ +export * from './ClientProvider'; +export * from './useAction'; +export * from './useClient'; +export * from './useClientCapability'; +export * from './useRequest'; export * from './useSignAndSendTransaction'; +export * from './useSubscription'; export * from './useSignIn'; export * from './useSignMessage'; export * from './useSignTransaction'; +export * from './useTrackedData'; export * from './useWalletAccountMessageSigner'; export * from './useWalletAccountTransactionSigner'; export * from './useWalletAccountTransactionSendingSigner'; diff --git a/packages/react/src/staticStores.ts b/packages/react/src/staticStores.ts new file mode 100644 index 000000000..475d658a0 --- /dev/null +++ b/packages/react/src/staticStores.ts @@ -0,0 +1,62 @@ +import { ReactiveActionStore, ReactiveState, ReactiveStreamStore } from '@solana/kit'; + +const DISABLED_ACTION_STATE = Object.freeze({ + data: undefined, + error: undefined, + status: 'idle' as const, +}); + +const IDLE_STREAM_STATE: ReactiveState = Object.freeze({ + data: undefined, + error: undefined, + status: 'idle', +}); + +const noopUnsubscribe = () => {}; +const noopSubscribe = () => noopUnsubscribe; +const rejectedAbortError = (): Promise => Promise.reject(new DOMException('Aborted', 'AbortError')); + +/** + * A {@link ReactiveActionStore} that never transitions out of `idle` and rejects any attempt to + * dispatch. Returned by `useRequest` (and other action-store hooks) when their factory function + * returns `null`, signalling that the call should be gated off — for example because a required + * input (an address, a query string) is not yet known. + * + * The hook's result bridge maps this store's `idle` state to a `disabled` status so call sites + * can distinguish "not enabled" from "loading" without an extra flag. + */ +export function disabledActionStore(): ReactiveActionStore<[], T> { + return { + dispatch: noopUnsubscribe, + dispatchAsync: rejectedAbortError, + getState: () => DISABLED_ACTION_STATE, + reset: noopUnsubscribe, + subscribe: noopSubscribe, + withSignal: () => ({ + dispatch: noopUnsubscribe, + dispatchAsync: rejectedAbortError, + }), + }; +} + +/** + * A {@link ReactiveStreamStore} that never transitions out of `idle` and ignores every + * `connect` / `retry` / `reset` / `subscribe` call. Returned by `useSubscription` when its + * source is `null`, signalling that the subscription should be gated off — for example because + * a required input (an address) is not yet known. + * + * The hook's result bridge maps this store's `idle` state to a `disabled` status so call sites + * can distinguish "not enabled" from "loading" without an extra flag. + */ +export function disabledStreamStore(): ReactiveStreamStore { + return { + connect: noopUnsubscribe, + getError: () => undefined, + getState: () => undefined, + getUnifiedState: () => IDLE_STREAM_STATE, + reset: noopUnsubscribe, + retry: noopUnsubscribe, + subscribe: noopSubscribe, + withSignal: () => ({ connect: noopUnsubscribe }), + }; +} diff --git a/packages/react/src/swr.ts b/packages/react/src/swr.ts new file mode 100644 index 000000000..2a617b617 --- /dev/null +++ b/packages/react/src/swr.ts @@ -0,0 +1,13 @@ +/** + * SWR-backed adapter for `@solana/react`. Bridges Kit's reactive primitives into SWR's + * cache so multiple components reading the same key share a single in-flight request, + * participate in SWR's revalidation triggers (focus, reconnect, polling), and show up in + * SWR's devtools. + * + * Import from `@solana/react/swr` — the subpath requires `swr` as a peer dep but is otherwise + * isolated from the core export so consumers who don't use SWR aren't forced to install it. + * + * @packageDocumentation + */ +export * from './swr/useRequestSwr'; +export * from './swr/useSubscriptionSwr'; diff --git a/packages/react/src/swr/__tests__/useRequestSwr-test.browser.tsx b/packages/react/src/swr/__tests__/useRequestSwr-test.browser.tsx new file mode 100644 index 000000000..76212a924 --- /dev/null +++ b/packages/react/src/swr/__tests__/useRequestSwr-test.browser.tsx @@ -0,0 +1,243 @@ +import { createReactiveActionStore, type ReactiveActionSource } from '@solana/kit'; +import { act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { SWRConfig } from 'swr'; + +import { renderHook } from '../../__test-utils__/render'; +import { useRequestSwr } from '../useRequestSwr'; + +// Wrap every render in an SWRConfig that: +// - Uses a fresh provider Map so cache state never leaks between tests. +// - Disables retries so a rejected fetch surfaces as `error` immediately instead of triggering +// SWR's exponential backoff. +// - Disables window-focus / network-reconnect revalidation so the test environment doesn't +// accidentally re-fire fetches during assertions. +function wrapper({ children }: { children: React.ReactNode }) { + return ( + new Map(), + revalidateOnFocus: false, + revalidateOnReconnect: false, + }} + > + {children} + + ); +} + +function makeFakeSource(): { + fn: jest.Mock, [AbortSignal]>; + rejectLatest: (err: unknown) => void; + resolveLatest: (value: T) => void; + source: ReactiveActionSource; +} { + let latest: PromiseWithResolvers | null = null; + const fn = jest.fn, [AbortSignal]>(() => { + latest = Promise.withResolvers(); + return latest.promise; + }); + return { + fn, + rejectLatest(err) { + latest!.reject(err); + }, + resolveLatest(value) { + latest!.resolve(value); + }, + source: { + reactiveStore() { + return createReactiveActionStore<[], T>(fn); + }, + }, + }; +} + +describe('useRequestSwr', () => { + describe('with a function source', () => { + it('auto-fires the fetcher on mount and transitions to data on success', async () => { + const { promise, resolve } = Promise.withResolvers(); + const fn = jest.fn, [AbortSignal]>(() => promise); + const { result } = renderHook(() => useRequestSwr(['fn-success'], fn), { wrapper }); + await waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + // Sync point — under SWR + jsdom, the cache entry needs to settle between the + // fetcher being invoked and the deferred promise being resolved. Accessing + // `result.current` here lets React commit the loading state before we trigger the + // resolution. + expect(result.current.data).toBeUndefined(); + + await act(async () => resolve('hi')); + await waitFor(() => expect(result.current.data).toBe('hi')); + expect(result.current.error).toBeUndefined(); + }); + + it('surfaces the rejection as `error`', async () => { + const boom = new Error('boom'); + const { promise, reject } = Promise.withResolvers(); + const fn = jest.fn, [AbortSignal]>(() => promise); + const { result } = renderHook(() => useRequestSwr(['fn-error'], fn), { wrapper }); + await waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + expect(result.current.error).toBeUndefined(); + + await act(async () => reject(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + }); + + it('threads an `AbortSignal` into the function', async () => { + const { promise, resolve } = Promise.withResolvers(); + const fn = jest.fn, [AbortSignal]>(() => promise); + renderHook(() => useRequestSwr(['signal-threaded'], fn), { wrapper }); + await waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + const signal = fn.mock.calls[0][0]; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + await act(async () => resolve('ok')); + }); + + it('passes the user-supplied `getAbortSignal` signal through to the function', async () => { + const { promise, resolve } = Promise.withResolvers(); + const fn = jest.fn, [AbortSignal]>(() => promise); + const ctrl = new AbortController(); + const getAbortSignal = jest.fn(() => ctrl.signal); + renderHook(() => useRequestSwr(['user-signal'], fn, { getAbortSignal }), { wrapper }); + await waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + expect(getAbortSignal).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0][0]).toBe(ctrl.signal); + await act(async () => resolve('ok')); + }); + + it('surfaces a `getAbortSignal`-driven abort as `result.error`', async () => { + const fn = jest.fn, [AbortSignal]>( + signal => + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason)); + }), + ); + const ctrl = new AbortController(); + const { result } = renderHook( + () => useRequestSwr(['user-signal-abort'], fn, { getAbortSignal: () => ctrl.signal }), + { wrapper }, + ); + await waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + expect(result.current.error).toBeUndefined(); + + const reason = new Error('timeout'); + await act(async () => ctrl.abort(reason)); + await waitFor(() => expect(result.current.error).toBe(reason)); + }); + }); + + describe('with a ReactiveActionSource (PendingRpc-like)', () => { + it('builds a store per fetch and resolves through `dispatchAsync()`', async () => { + const req = makeFakeSource(); + const { result } = renderHook(() => useRequestSwr(['source-success'], req.source), { wrapper }); + await waitFor(() => expect(req.fn).toHaveBeenCalledTimes(1)); + expect(result.current.data).toBeUndefined(); + + await act(async () => req.resolveLatest('value')); + await waitFor(() => expect(result.current.data).toBe('value')); + }); + + it('surfaces the rejection as `error`', async () => { + const boom = new Error('boom'); + const req = makeFakeSource(); + const { result } = renderHook(() => useRequestSwr(['source-error'], req.source), { wrapper }); + await waitFor(() => expect(req.fn).toHaveBeenCalledTimes(1)); + expect(result.current.error).toBeUndefined(); + + await act(async () => req.rejectLatest(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + }); + + it('routes the user-supplied `getAbortSignal` signal through `withSignal`', async () => { + const req = makeFakeSource(); + const ctrl = new AbortController(); + const { result } = renderHook( + () => useRequestSwr(['source-user-signal'], req.source, { getAbortSignal: () => ctrl.signal }), + { wrapper }, + ); + await waitFor(() => expect(req.fn).toHaveBeenCalledTimes(1)); + expect(result.current.error).toBeUndefined(); + + const reason = new Error('timeout'); + await act(async () => ctrl.abort(reason)); + await waitFor(() => expect(result.current.error).toBe(reason)); + }); + }); + + it('skips the fetch when the key is null', async () => { + const fn = jest.fn, [AbortSignal]>(() => Promise.resolve('never')); + const { result } = renderHook(() => useRequestSwr(null, fn), { wrapper }); + // Wait a tick to be sure SWR hasn't queued a fetch. + await act(async () => { + await Promise.resolve(); + }); + expect(fn).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + }); + + it('skips the fetch when the source is null (even if the key is non-null)', async () => { + const fn = jest.fn, [AbortSignal]>(() => Promise.resolve('never')); + const initialProps: { source: ((signal: AbortSignal) => Promise) | null } = { source: null }; + const { result, rerender } = renderHook(({ source }) => useRequestSwr(['key-set'], source), { + initialProps, + wrapper, + }); + await act(async () => { + await Promise.resolve(); + }); + expect(fn).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + + // Transition to a real source — fetch fires. + rerender({ source: fn }); + await waitFor(() => expect(result.current.data).toBe('never')); + }); + + it('starts firing once the key transitions from null to non-null', async () => { + const fn = jest.fn, [AbortSignal]>(() => Promise.resolve('value')); + const initialProps: { key: string | null } = { key: null }; + const { result, rerender } = renderHook(({ key }) => useRequestSwr(key, fn), { initialProps, wrapper }); + expect(fn).not.toHaveBeenCalled(); + + rerender({ key: 'now-enabled' }); + await waitFor(() => expect(result.current.data).toBe('value')); + }); + + it('uses the latest source closure when `mutate()` is called', async () => { + // SWR keys cache lookup — if the key is stable, a source-identity change alone won't + // refetch. But the next fetch (e.g. via `mutate()`) should run the latest source. This + // mirrors how SWR users normally encode source-dependent inputs in the key. + const { result, rerender } = renderHook( + ({ value }: { value: string }) => useRequestSwr(['latest-closure'], () => Promise.resolve(value)), + { initialProps: { value: 'a' }, wrapper }, + ); + await waitFor(() => expect(result.current.data).toBe('a')); + + rerender({ value: 'b' }); + await act(async () => { + await result.current.mutate(); + }); + await waitFor(() => expect(result.current.data).toBe('b')); + }); + + describe('SSR', () => { + it('renders without firing the fetcher', () => { + const fn = jest.fn, [AbortSignal]>(() => Promise.resolve('never')); + function Component() { + const { data } = useRequestSwr(['ssr'], fn); + return

{data ?? 'no-data'}

; + } + const html = renderToString( + new Map() }}> + + , + ); + expect(html).toBe('

no-data

'); + // SWR fires the fetcher inside an effect — on the server, effects don't run. + expect(fn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react/src/swr/__tests__/useSubscriptionSwr-test.browser.tsx b/packages/react/src/swr/__tests__/useSubscriptionSwr-test.browser.tsx new file mode 100644 index 000000000..98899fc5d --- /dev/null +++ b/packages/react/src/swr/__tests__/useSubscriptionSwr-test.browser.tsx @@ -0,0 +1,163 @@ +import { + createReactiveStoreFromDataPublisherFactory, + DataPublisher, + ReactiveStreamSource, + SolanaRpcResponse, +} from '@solana/kit'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { SWRConfig } from 'swr'; + +import { useSubscriptionSwr } from '../useSubscriptionSwr'; + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + new Map() }}> + {children} + + ); +} + +type Notification = SolanaRpcResponse | T; + +function makeFakeSubscription(): { + publish: (notification: Notification) => Promise; + publishError: (err: unknown) => Promise; + source: ReactiveStreamSource; +} { + type Listener = (payload: unknown) => void; + let dataListeners: Listener[] = []; + let errorListeners: Listener[] = []; + let publisherReady = Promise.withResolvers(); + return { + async publish(notification) { + await publisherReady.promise; + dataListeners.forEach(fn => fn(notification)); + }, + async publishError(err) { + await publisherReady.promise; + errorListeners.forEach(fn => fn(err)); + }, + source: { + reactiveStore() { + return createReactiveStoreFromDataPublisherFactory({ + createDataPublisher() { + dataListeners = []; + errorListeners = []; + publisherReady = Promise.withResolvers(); + let onCallCount = 0; + const publisher: DataPublisher = { + on(channel, listener, options) { + const list = channel === 'data' ? dataListeners : errorListeners; + list.push(listener); + options?.signal.addEventListener( + 'abort', + () => { + const idx = list.indexOf(listener); + if (idx !== -1) list.splice(idx, 1); + }, + { once: true }, + ); + if (++onCallCount === 2) publisherReady.resolve(); + return () => { + const idx = list.indexOf(listener); + if (idx !== -1) list.splice(idx, 1); + }; + }, + }; + return Promise.resolve(publisher); + }, + dataChannelName: 'data', + errorChannelName: 'error', + }); + }, + }, + }; +} + +describe('useSubscriptionSwr', () => { + it('surfaces `SolanaRpcResponse` envelopes as-is — callers read `.value` and `.context.slot`', async () => { + const sub = makeFakeSubscription>(); + const { result } = renderHook(() => useSubscriptionSwr(['account'], sub.source), { wrapper }); + // Sync point — let SWR's subscription wire up before the first publish. + expect(result.current.data).toBeUndefined(); + + await act(async () => sub.publish({ context: { slot: 99n }, value: { lamports: 5n } })); + await waitFor(() => + expect(result.current.data).toStrictEqual({ context: { slot: 99n }, value: { lamports: 5n } }), + ); + }); + + it('passes raw notifications through unchanged', async () => { + const sub = makeFakeSubscription<{ slot: bigint }>(); + const { result } = renderHook(() => useSubscriptionSwr(['slot'], sub.source), { wrapper }); + expect(result.current.data).toBeUndefined(); + + await act(async () => sub.publish({ slot: 10n })); + await waitFor(() => expect(result.current.data).toStrictEqual({ slot: 10n })); + }); + + it('surfaces error-channel publishes as `result.error`', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const { result } = renderHook(() => useSubscriptionSwr(['err'], sub.source), { wrapper }); + expect(result.current.error).toBeUndefined(); + + const boom = new Error('boom'); + await act(async () => sub.publishError(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + }); + + it('skips the subscription when the key is null', async () => { + const reactiveStore = jest.fn(); + const source: ReactiveStreamSource<{ value: number }> = { reactiveStore }; + renderHook(() => useSubscriptionSwr(null, source), { wrapper }); + await act(async () => { + await Promise.resolve(); + }); + expect(reactiveStore).not.toHaveBeenCalled(); + }); + + it('skips the subscription when the source is null (even if the key is non-null)', async () => { + const { result } = renderHook( + () => useSubscriptionSwr<{ value: number }>(['key-set'], null), + { wrapper }, + ); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.data).toBeUndefined(); + }); + + it('passes the user-supplied `getAbortSignal` signal into `withSignal`', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const ctrl = new AbortController(); + const { result } = renderHook( + () => useSubscriptionSwr(['user-signal'], sub.source, { getAbortSignal: () => ctrl.signal }), + { wrapper }, + ); + expect(result.current.error).toBeUndefined(); + + const reason = new Error('timeout'); + await act(async () => ctrl.abort(reason)); + await waitFor(() => expect(result.current.error).toBe(reason)); + }); + + describe('SSR', () => { + it('renders without opening the subscription', () => { + const reactiveStore = jest.fn(); + const source: ReactiveStreamSource<{ value: number }> = { reactiveStore }; + function Component() { + const { data } = useSubscriptionSwr(['ssr'], source); + return

{data ? 'has-data' : 'no-data'}

; + } + const html = renderToString( + new Map() }}> + + , + ); + expect(html).toBe('

no-data

'); + expect(reactiveStore).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react/src/swr/__typetests__/useRequestSwr-typetest.ts b/packages/react/src/swr/__typetests__/useRequestSwr-typetest.ts new file mode 100644 index 000000000..4b4e0c4b9 --- /dev/null +++ b/packages/react/src/swr/__typetests__/useRequestSwr-typetest.ts @@ -0,0 +1,60 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { ReactiveActionSource } from '@solana/kit'; +import type { SWRResponse } from 'swr'; + +import { useRequestSwr } from '../useRequestSwr'; + +const fnSource = null as unknown as (signal: AbortSignal) => Promise<{ slot: bigint }>; +const reactiveSource = null as unknown as ReactiveActionSource<{ slot: bigint }>; + +// [DESCRIBE] useRequestSwr +{ + // Function source — returns SWRResponse with the resolved type. + useRequestSwr(['epochInfo'], fnSource) satisfies SWRResponse<{ slot: bigint }>; + + // ReactiveActionSource — returns SWRResponse with the source's value type. + useRequestSwr(['balance'], reactiveSource) satisfies SWRResponse<{ slot: bigint }>; + + // `data` is `T | undefined` until the first successful fetch. + const { data } = useRequestSwr(['epochInfo'], fnSource); + data satisfies { slot: bigint } | undefined; + + // Null key is accepted (disables the fetch). + useRequestSwr(null, fnSource) satisfies SWRResponse<{ slot: bigint }>; + + // Null source is accepted (also disables the fetch). + useRequestSwr<{ slot: bigint }>(['epochInfo'], null) satisfies SWRResponse<{ slot: bigint }>; + + // Default `TError` is `Error`. + const { error } = useRequestSwr(['epochInfo'], fnSource); + error satisfies Error | undefined; + + // `TError` is overridable via the generic. + useRequestSwr<{ slot: bigint }, string>(['epochInfo'], fnSource).error satisfies string | undefined; + + // Options are forwarded to SWR's configuration. + useRequestSwr(['epochInfo'], fnSource, { + // @ts-expect-error - SWR doesn't accept arbitrary keys. + notARealOption: true, + revalidateOnFocus: false, + }); + + // Kit-specific options merge into the SWR options bag. + useRequestSwr(['epochInfo'], fnSource, { + getAbortSignal: () => AbortSignal.timeout(5_000), + revalidateOnFocus: false, + }); + + // `getAbortSignal` must return an AbortSignal (or undefined when omitted). + useRequestSwr(['epochInfo'], fnSource, { + // @ts-expect-error - factory must return AbortSignal. + getAbortSignal: () => 'not a signal', + }); + + // Function source must return `Promise` and accept an AbortSignal. + useRequestSwr(['epochInfo'], (signal: AbortSignal) => { + signal satisfies AbortSignal; + return Promise.resolve({ slot: 1n }); + }); +} diff --git a/packages/react/src/swr/__typetests__/useSubscriptionSwr-typetest.ts b/packages/react/src/swr/__typetests__/useSubscriptionSwr-typetest.ts new file mode 100644 index 000000000..ca8d708a2 --- /dev/null +++ b/packages/react/src/swr/__typetests__/useSubscriptionSwr-typetest.ts @@ -0,0 +1,34 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { ReactiveStreamSource, SolanaRpcResponse } from '@solana/kit'; + +import { useSubscriptionSwr } from '../useSubscriptionSwr'; + +const accountSource = null as unknown as ReactiveStreamSource>; +const slotSource = null as unknown as ReactiveStreamSource<{ slot: bigint }>; + +// [DESCRIBE] useSubscriptionSwr +{ + // Envelope sources surface the envelope unchanged — callers read `data.value` and + // `data.context.slot` directly. + const envelope = useSubscriptionSwr(['account'], accountSource); + envelope.data satisfies SolanaRpcResponse<{ lamports: bigint }> | undefined; + envelope.data?.value satisfies { lamports: bigint } | undefined; + envelope.data?.context.slot satisfies bigint | undefined; + + // Raw notifications pass through unchanged. + const raw = useSubscriptionSwr(['slot'], slotSource); + raw.data satisfies { slot: bigint } | undefined; + + // Null key disables. + useSubscriptionSwr(null, slotSource); + + // Null source disables. + useSubscriptionSwr<{ slot: bigint }>(['slot'], null); + + // Options merge SWR's config with Kit's per-attempt signal factory. + useSubscriptionSwr(['slot'], slotSource, { + getAbortSignal: () => AbortSignal.timeout(30_000), + revalidateOnFocus: false, + }); +} diff --git a/packages/react/src/swr/useRequestSwr.ts b/packages/react/src/swr/useRequestSwr.ts new file mode 100644 index 000000000..eca5adb49 --- /dev/null +++ b/packages/react/src/swr/useRequestSwr.ts @@ -0,0 +1,68 @@ +import type { ReactiveActionSource } from '@solana/kit'; +import { useCallback, useRef } from 'react'; +import useSWR, { type Key as SWRKey, type SWRConfiguration, type SWRResponse } from 'swr'; + +import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; +import type { UseRequestOptions } from '../useRequest'; + +/** + * SWR-backed counterpart to core's `useRequest`. Accepts the same source shape — a + * {@link ReactiveActionSource} (satisfied by `PendingRpcRequest`) or a + * `(signal: AbortSignal) => Promise` function — and routes it through SWR so components + * reading the same `key` share one in-flight fetch and participate in SWR's revalidation, + * persistence, and devtools. + * + * Pass `null` for `key` or `source` to disable — useful when one of the source's inputs isn't + * yet known. Include source inputs in `key` to partition the cache. Use `result.mutate()` to + * re-fire on demand (no Kit-specific `refresh` — SWR owns that). + * + * @typeParam T - The value the underlying request resolves to. + * @typeParam TError - The error type SWR will surface on failure. + * + * @example + * ```tsx + * function EpochInfo() { + * const client = useClient>(); + * const { data } = useRequestSwr(['epochInfo'], client.rpc.getEpochInfo()); + * return

{data ? `Epoch ${data.epoch}` : 'Loading…'}

; + * } + * + * // Per-attempt timeout via `getAbortSignal` (same option as `useRequest`). + * useRequestSwr(['epochInfo'], source, { getAbortSignal: () => AbortSignal.timeout(5_000) }); + * ``` + */ +export function useRequestSwr( + key: SWRKey, + source: ReactiveActionSource | ((signal: AbortSignal) => Promise) | null, + options?: SWRConfiguration & UseRequestOptions, +): SWRResponse { + // Split our option off the SWR config so we can hand the rest to `useSWR` cleanly. + const { getAbortSignal, ...swrConfig } = options ?? {}; + + // Ref-sync the source and the abort-signal factory so an inline closure passed each render + // doesn't change the fetcher's identity. The fetcher reads the latest values from the refs + // when it fires; SWR uses the key (not the fetcher identity) for cache lookup. + const sourceRef = useRef(source); + const getAbortSignalRef = useRef(getAbortSignal); + useIsomorphicLayoutEffect(() => { + sourceRef.current = source; + getAbortSignalRef.current = getAbortSignal; + }); + + const fetcher = useCallback(async (): Promise => { + const userSignal = getAbortSignalRef.current?.(); + const current = sourceRef.current!; + if (typeof current === 'function') { + // The source's signature requires an `AbortSignal` — hand it the user's signal if + // one was configured, otherwise a fresh never-aborted signal so the type fits. + return await current(userSignal ?? new AbortController().signal); + } + if (userSignal) { + return await current.reactiveStore().withSignal(userSignal).dispatchAsync(); + } + return await current.reactiveStore().dispatchAsync(); + }, []); + + // Force the key to `null` when there's no source — either-null disables the fetch. + return useSWR(source == null ? null : key, fetcher, swrConfig); +} diff --git a/packages/react/src/swr/useSubscriptionSwr.ts b/packages/react/src/swr/useSubscriptionSwr.ts new file mode 100644 index 000000000..156989ddc --- /dev/null +++ b/packages/react/src/swr/useSubscriptionSwr.ts @@ -0,0 +1,75 @@ +import type { ReactiveStreamSource } from '@solana/kit'; +import { useCallback, useRef } from 'react'; +import type { Key as SWRKey, SWRConfiguration } from 'swr'; +import useSWRSubscription, { type SWRSubscriptionOptions, type SWRSubscriptionResponse } from 'swr/subscription'; + +import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; +import type { UseSubscriptionOptions } from '../useSubscription'; + +/** + * SWR-backed counterpart to `useSubscription`. Routes a `ReactiveStreamSource` through SWR's + * subscription cache (`useSWRSubscription`) so components reading the same `key` share one + * underlying connection and participate in SWR's devtools / cache layer. + * + * Returns SWR's native `{ data, error }` shape. `data` is the notification exactly as the source + * emits it — no unwrapping. For RPC subscriptions that emit `SolanaRpcResponse` envelopes + * (`accountNotifications`, `programNotifications`, `signatureNotifications`), read the inner + * value at `data.value` and the slot at `data.context.slot`. For raw notifications + * (`slotNotifications`, `logsNotifications`, `rootNotifications`), `data` is the raw shape. + * + * Pass `null` for `key` or `source` to disable. + * + * @typeParam T - The notification type emitted by the source. + * + * @example + * ```tsx + * function AccountBalance({ address }: { address: Address }) { + * const client = useClient>(); + * const { data } = useSubscriptionSwr( + * address ? ['account', address] : null, + * address ? client.rpcSubscriptions.accountNotifications(address) : null, + * ); + * return

{data ? `${data.value.lamports} lamports at slot ${data.context.slot}` : 'Connecting…'}

; + * } + * ``` + */ +export function useSubscriptionSwr( + key: SWRKey, + source: ReactiveStreamSource | null, + options?: SWRConfiguration & UseSubscriptionOptions, +): SWRSubscriptionResponse { + const { getAbortSignal, ...swrConfig } = options ?? {}; + + // Ref-sync the source and the abort-signal factory so an inline value passed each render + // doesn't change the `subscribe` callback's identity. `subscribe` reads the latest values + // from the refs when SWR invokes it. + const sourceRef = useRef(source); + const getAbortSignalRef = useRef(getAbortSignal); + useIsomorphicLayoutEffect(() => { + sourceRef.current = source; + getAbortSignalRef.current = getAbortSignal; + }); + + const subscribe = useCallback((_key: SWRKey, { next }: SWRSubscriptionOptions) => { + const current = sourceRef.current!; + const store = current.reactiveStore(); + const unsubscribe = store.subscribe(() => { + const state = store.getUnifiedState(); + if (state.status === 'loaded') { + next(null, state.data); + } else if (state.status === 'error') { + next(state.error); + } + }); + const userSignal = getAbortSignalRef.current?.(); + if (userSignal) store.withSignal(userSignal).connect(); + else store.connect(); + return () => { + unsubscribe(); + store.reset(); + }; + }, []); + + // Force the key to `null` when there's no source — either-null disables the subscription. + return useSWRSubscription(source == null ? null : key, subscribe, swrConfig); +} diff --git a/packages/react/src/useAction.ts b/packages/react/src/useAction.ts new file mode 100644 index 000000000..a46356f05 --- /dev/null +++ b/packages/react/src/useAction.ts @@ -0,0 +1,132 @@ +import { createReactiveActionStore } from '@solana/kit'; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; + +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; + +/** + * Reactive state and controls for an async action managed by {@link useAction} + * (and plugin-specific hooks built on top of it). + * + * Lifecycle: starts at `idle`. Each `dispatch(...)` flips to `running`, then to `success` or + * `error` depending on the outcome. `data` from a prior `success` and `error` from a prior failure + * both persist through subsequent `running` states for stale-while-revalidate UX. `success` clears + * `error`; only `reset()` clears `data`. + * + * Calling `dispatch(...)` while a previous call is in flight aborts the first via its + * `AbortSignal` and replaces it. Awaiters of the superseded call see a rejection with an + * `AbortError`, filterable via `isAbortError` from `@solana/promises`. + * + * @typeParam TArgs - The argument tuple `dispatch` accepts; forwarded to the wrapped function + * after the abort signal. + * @typeParam TResult - The value the wrapped function resolves to on success. + */ +export type ActionResult = { + /** The result on success, or `undefined` if no successful call has happened yet. */ + data: TResult | undefined; + /** + * Trigger the action. Resolves with the wrapped function's result, or rejects with the thrown + * error. Calling `dispatch` again while a prior call is in flight aborts the first and rejects + * its promise with an `AbortError`. Stable reference. + * + * Mirrors `ReactiveActionStore.dispatchAsync` — combined into a single function on the hook + * because React event handlers typically don't await the result. Fire-and-forget callers can + * ignore the returned promise and render from `status` / `data` / `error`. Awaiters that read + * the resolved value (e.g. to navigate on success) should filter supersede rejections with + * `isAbortError` from `@solana/promises`. + */ + dispatch: (...args: TArgs) => Promise; + /** + * The error from the most recent failed call, or `undefined`. Persists through a subsequent + * `running` state so UIs can keep showing the prior failure while a retry is in flight; a + * subsequent `success` clears it. + */ + error: unknown; + /** `true` when `status === 'error'`. */ + isError: boolean; + /** `true` when `status === 'idle'`. */ + isIdle: boolean; + /** `true` when `status === 'running'` — a dispatch is in flight. */ + isRunning: boolean; + /** `true` when `status === 'success'`. */ + isSuccess: boolean; + /** Reset state back to `idle`, aborting any in-flight call. Stable reference. */ + reset: () => void; + /** + * The current lifecycle status as a discriminated string. The `isIdle` / `isRunning` / + * `isSuccess` / `isError` booleans below are derived from this — pick whichever reads better + * at the call site. + */ + status: 'error' | 'idle' | 'running' | 'success'; +}; + +/** + * Bridge an arbitrary async function into a reactive {@link ActionResult}. Each `dispatch(...)` + * call runs the function with a fresh {@link AbortSignal} and tracks its lifecycle through React + * state; a second call while a first is in flight aborts the first. + * + * `fn` is held in a ref that always points at the latest closure — there is no `deps` array to + * maintain. Each `dispatch(...)` invokes the most recently rendered `fn`, so values captured + * inside (e.g. form state, route params) are always fresh without explicit dependency tracking. + * In-flight calls are unaffected — they continue with the closure they captured at dispatch time. + * + * @typeParam TArgs - The argument tuple `dispatch` accepts; forwarded to `fn` after the abort + * signal. + * @typeParam TResult - The value `fn` resolves to on success. + * + * @example + * ```tsx + * import { useAction } from '@solana/react'; + * + * function PostMessageButton({ url, body }: { url: string; body: string }) { + * const { dispatch, isRunning, error } = useAction(async (signal, content: string) => { + * const res = await fetch(url, { body: content, method: 'POST', signal }); + * if (!res.ok) throw new Error(`HTTP ${res.status}`); + * return res.json() as Promise<{ id: string }>; + * }); + * return ( + * + * ); + * } + * ``` + * + * @see {@link ActionResult} + */ +export function useAction( + fn: (signal: AbortSignal, ...args: TArgs) => Promise, +): ActionResult { + // Stable callback over the latest closure. Similar to `useEffectEvent`, but we need to + // pass the callback to `createReactiveActionStore` so need to implement the pattern manually. + const fnRef = useRef(fn); + useIsomorphicLayoutEffect(() => { + fnRef.current = fn; + }); + + // `createReactiveActionStore` only reads the callback when the returned `dispatch` is + // called, not during render. The `react-hooks/refs` rule doesn't know that, so we silence it. + // eslint-disable-next-line react-hooks/refs + const [store] = useState(() => + createReactiveActionStore((signal, ...args) => fnRef.current(signal, ...args)), + ); + + // Reset on unmount so any in-flight call is aborted and state is dropped. + useEffect(() => () => store.reset(), [store]); + + const state = useSyncExternalStore(store.subscribe, store.getState, store.getState); + + return useMemo( + () => ({ + data: state.data, + dispatch: store.dispatchAsync, + error: state.error, + isError: state.status === 'error', + isIdle: state.status === 'idle', + isRunning: state.status === 'running', + isSuccess: state.status === 'success', + reset: store.reset, + status: state.status, + }), + [state, store], + ); +} diff --git a/packages/react/src/useClient.ts b/packages/react/src/useClient.ts new file mode 100644 index 000000000..be4328a8f --- /dev/null +++ b/packages/react/src/useClient.ts @@ -0,0 +1,38 @@ +import { type Client, SOLANA_ERROR__REACT__MISSING_PROVIDER, SolanaError } from '@solana/kit'; +import React from 'react'; + +import { ClientContext } from './ClientProvider'; + +/** + * Reads the Kit client published by the nearest {@link ClientProvider}. Throws a + * {@link SolanaError} with code {@link SOLANA_ERROR__REACT__MISSING_PROVIDER} if no provider is + * mounted in the ancestor tree. + * + * Defaults to the base {@link Client} shape. Callers who know a specific plugin is installed may + * widen the type via the generic — this is a pure cast with no runtime capability check, so reach + * for {@link useClientCapability} when a missing plugin should fail loudly at mount instead of + * surfacing later as `undefined`. + * + * @typeParam TClient - The shape the client is expected to satisfy. Pure type assertion. + * + * @example + * ```tsx + * import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; + * import { useClient } from '@solana/react'; + * + * function ManualSend() { + * const client = useClient>(); + * return ; + * } + * ``` + * + * @see {@link ClientProvider} + * @see {@link useClientCapability} + */ +export function useClient(): Client { + const client = React.useContext(ClientContext); + if (client == null) { + throw new SolanaError(SOLANA_ERROR__REACT__MISSING_PROVIDER, { hookName: 'useClient' }); + } + return client as Client; +} diff --git a/packages/react/src/useClientCapability.ts b/packages/react/src/useClientCapability.ts new file mode 100644 index 000000000..4fca02a3b --- /dev/null +++ b/packages/react/src/useClientCapability.ts @@ -0,0 +1,74 @@ +import { type Client, SOLANA_ERROR__REACT__MISSING_CAPABILITY, SolanaError } from '@solana/kit'; + +import { useClient } from './useClient'; + +/** + * Configuration for {@link useClientCapability}. + */ +export type UseClientCapabilityConfig = Readonly<{ + /** + * The capability name (or names) the hook depends on. Each is checked against the client with + * a runtime `in` test before the narrowed value is returned. Pass an array when the hook + * needs multiple capabilities (e.g. `['rpc', 'rpcSubscriptions']`); the same `providerHint` is + * used for any that's missing. + */ + capability: string | readonly string[]; + /** + * Name of the calling hook, surfaced in the missing-capability error so users can locate the + * call site quickly. + */ + hookName: string; + /** + * Free-form actionable hint shown alongside the error — usually a one-liner naming the plugin + * (or family of plugins) the user should install. + */ + providerHint: string; +}>; + +/** + * Reads the client from the nearest {@link ClientProvider} and asserts at mount that the + * requested capability is installed, narrowing the return type via the generic. Throws a + * {@link SolanaError} with code {@link SOLANA_ERROR__REACT__MISSING_CAPABILITY} when the + * capability is absent — including the calling `hookName` and a `providerHint` so users can fix + * the mistake without cross-referencing docs. + * + * Use this from the implementation of plugin-specific hooks. Apps that need ad-hoc access without + * a runtime check can reach for {@link useClient} directly and supply their own type narrowing. + * + * @typeParam TClient - The narrowed client shape returned once the capability assertion passes. + * Always pass this generic — the hook can't infer it from a string. + * + * @example + * ```ts + * import { ClientWithRpc, GetEpochInfoApi } from '@solana/kit'; + * import { useClientCapability } from '@solana/react'; + * + * function useRpc() { + * return useClientCapability>({ + * capability: 'rpc', + * hookName: 'useRpc', + * providerHint: 'Install `solanaRpc()` on the client.', + * }); + * } + * ``` + * + * @see {@link useClient} + * @see {@link ClientProvider} + */ +export function useClientCapability({ + capability, + hookName, + providerHint, +}: UseClientCapabilityConfig): Client { + const client = useClient(); + const required = typeof capability === 'string' ? [capability] : capability; + const missing = required.filter(name => !Object.hasOwn(client, name)); + if (missing.length > 0) { + throw new SolanaError(SOLANA_ERROR__REACT__MISSING_CAPABILITY, { + capabilities: missing, + hookName, + providerHint, + }); + } + return client; +} diff --git a/packages/react/src/useIsomorphicLayoutEffect.ts b/packages/react/src/useIsomorphicLayoutEffect.ts new file mode 100644 index 000000000..4dd5e2f75 --- /dev/null +++ b/packages/react/src/useIsomorphicLayoutEffect.ts @@ -0,0 +1,11 @@ +import { useEffect, useLayoutEffect } from 'react'; + +/** + * `useLayoutEffect` warns when run on the server because layout effects only make sense after a + * DOM is mounted. For our use, plain `useEffect` is functionally equivalent on the server (no + * event handlers can fire mid-render anyway), so we pick at module load time and stay silent + * during SSR. + * + * @internal + */ +export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; diff --git a/packages/react/src/usePromise.ts b/packages/react/src/usePromise.ts new file mode 100644 index 000000000..bdc52dd64 --- /dev/null +++ b/packages/react/src/usePromise.ts @@ -0,0 +1,69 @@ +import React from 'react'; + +type CacheEntry = + | Readonly<{ promise: PromiseLike; status: 'pending' }> + | Readonly<{ reason: unknown; status: 'rejected' }> + | Readonly<{ status: 'fulfilled'; value: T }>; + +const cache = new WeakMap, CacheEntry>(); + +function trackedPromise(promise: PromiseLike): PromiseLike { + // Returns the *chained* promise (not the original). React subscribes to the thrown value to + // know when to retry; throwing the chained one means retry runs strictly after our + // cache-update handler, so the next render is guaranteed to see the settled entry. + return promise.then( + value => { + cache.set(promise, { status: 'fulfilled', value }); + }, + reason => { + cache.set(promise, { reason, status: 'rejected' }); + }, + ); +} + +/** + * React 18 fallback for `React.use(promise)` — suspends by throwing the promise on first render, + * returns the resolved value on subsequent renders, and re-throws the rejection if the promise + * settles with an error. + * + * The promise identity must be stable across renders so the cache keyed off it can find the + * settled entry on the second render. The {@link ClientProvider}'s consumer-facing contract + * documents this — pass a memoised or module-scope promise. + */ +function usePromiseShim(promise: PromiseLike): T { + let entry = cache.get(promise) as CacheEntry | undefined; + if (entry == null) { + const tracked = trackedPromise(promise); + entry = { promise: tracked, status: 'pending' }; + cache.set(promise, entry); + } + if (entry.status === 'pending') throw entry.promise; + if (entry.status === 'rejected') throw entry.reason; + return entry.value; +} + +type ReactWithUse = typeof React & { + use?: (promise: PromiseLike) => T; +}; + +function isPromiseLike(value: PromiseLike | T): value is PromiseLike { + return ( + !!value && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as PromiseLike).then === 'function' + ); +} + +/** + * Returns `value` directly if it is a plain value, or unwraps it (suspending the subtree until + * it settles) if it is a {@link PromiseLike}. Designed to be invoked unconditionally so consumers + * don't have to gate on the runtime shape of the input — that keeps the call site + * rules-of-hooks-friendly even when the same provider sees both sync and async clients. + * + * The `React.use` lookup is deferred to call time so the module has no top-level side effects + * (agadoo's tree-shake check fails otherwise on the property-access expression). + */ +export function usePromise(value: PromiseLike | T): T { + if (!isPromiseLike(value)) return value; + return ((React as ReactWithUse).use ?? usePromiseShim)(value); +} diff --git a/packages/react/src/useRequest.ts b/packages/react/src/useRequest.ts new file mode 100644 index 000000000..fe5fc3578 --- /dev/null +++ b/packages/react/src/useRequest.ts @@ -0,0 +1,163 @@ +import { createReactiveActionStore, ReactiveActionSource } from '@solana/kit'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { disabledActionStore } from './staticStores'; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; +import { useRequestResult } from './useRequestResult'; + +/** + * Reactive state for a one-shot request managed by {@link useRequest}. + * + * Lifecycle: starts at `fetching` (or `disabled` when the source is `null`) and auto-fires on + * mount; transitions to `success` on success or `error` on failure. `refresh()` re-fires the + * request — while a re-fire is in flight, `status` returns to `fetching` and the stale `data` + * and/or `error` from the prior outcome remain populated (stale-while-revalidate). + * + * @typeParam T - The value the underlying request resolves to. + */ +export type RequestResult = { + /** + * The most recent successful value, or `undefined` if no call has succeeded yet (or while + * disabled). Persists across subsequent refreshes (including failed ones) until a new success + * replaces it or `reset()` clears the store. + */ + data: T | undefined; + /** + * The error from the most recent failed call, or `undefined` if no call has failed (or while + * disabled). Persists across subsequent refreshes until a new success clears it. May coexist + * with `data` when a successful attempt is followed by a failing one. + */ + error: unknown; + /** + * Re-fire the request. By default each call mints a fresh signal from `getAbortSignal` (if + * configured) and threads it through the underlying store's + * `withSignal(signal).dispatch()`. Pass `{ abortSignal }` to override the configured factory + * for just this attempt. Pass `{ abortSignal: undefined }` to opt out of the factory entirely + * for this attempt and fire with no caller-provided signal. + * + * Stable reference. + */ + refresh: (options?: { abortSignal?: AbortSignal | undefined }) => void; + /** + * Lifecycle status as a discriminated string: + * - `fetching`: a request is in flight. `data` and `error` carry whatever stale content was + * available before this attempt (both `undefined` on the first attempt; either or both + * populated on a refresh after a prior outcome). + * - `success`: most recent call succeeded; `data` holds the result. + * - `error`: most recent call failed; `error` holds the reason. + * - `disabled`: source was `null`. + */ + status: 'disabled' | 'error' | 'fetching' | 'success'; +}; + +/** Options accepted by {@link useRequest}. */ +export type UseRequestOptions = { + /** + * Factory invoked on every attempt (initial fire + every `refresh()`). The returned signal is + * attached to that attempt via the underlying store's `withSignal(signal).dispatch()`, so + * aborting it cancels just the current attempt. + * + * The most common use is per-attempt timeouts: `getAbortSignal: () => AbortSignal.timeout(5000)` + * gives every attempt its own 5-second clock that resets on `refresh()`. + * + * Held in a ref synced to the latest render's closure — there is no need to memoize an inline + * factory. + */ + getAbortSignal?: () => AbortSignal; +}; + +/** + * Fire a one-shot request on mount and re-fire each time `source` changes identity or `refresh()` + * is called. Returns reactive state tracking the call's lifecycle. + * + * Two ways to pass the work: + * + * - A {@link ReactiveActionSource} — the `{ reactiveStore() }` duck-type satisfied by + * `PendingRpcRequest`. + * - An async function `(signal: AbortSignal) => Promise` — wrap any one-shot async source + * (a `fetch`, a third-party SDK call, etc). Most general shape. + * + * Pass `null` to disable; the result reports `status: 'disabled'`. + * + * Memoize the input (`useMemo` for a source, `useCallback` for a function) keyed on whatever + * inputs it depends on. + * + * @typeParam T - The value the underlying request resolves to. + * + * @example + * ```tsx + * function LatestBlockhash() { + * const client = useClient>(); + * const source = useMemo(() => client.rpc.getLatestBlockhash(), [client]); + * const { data, error, refresh } = useRequest(source, { + * getAbortSignal: () => AbortSignal.timeout(5_000), + * }); + * if (error) return ; + * return

{data ? `Blockhash: ${data.value.blockhash}` : 'Loading…'}

; + * } + * + * // Function shape — wraps an arbitrary async call: + * function Profile({ userId }: { userId: string }) { + * const fetcher = useCallback( + * (signal: AbortSignal) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()), + * [userId], + * ); + * const { data, error, refresh } = useRequest(fetcher); + * if (error) return ; + * return

{data ? data.name : 'Loading…'}

; + * } + * ``` + * + * @see {@link RequestResult} + * @see {@link UseRequestOptions} + */ +export function useRequest( + source: ReactiveActionSource | ((signal: AbortSignal) => Promise) | null, + options?: UseRequestOptions, +): RequestResult { + // Ref-sync the per-request factory so inline closures don't churn the memo below. Each + // dispatch invokes the latest factory at fire time. + const getAbortSignalRef = useRef(options?.getAbortSignal); + useIsomorphicLayoutEffect(() => { + getAbortSignalRef.current = options?.getAbortSignal; + }); + + // One store per `source`. Both creation paths return an `idle` store; the initial dispatch + // lives in the effect below so the memo body stays pure (StrictMode's dev double-render, and + // any future render-discard, won't fire a network request from a discarded render). + // + // Note: `useMemo` is permitted to discard memoized values across renders (independent of deps); + // if React ever does so here, the discard surfaces as a fresh store with empty `data` / + // `error` and an immediate refetch — same shape as a remount. In practice this doesn't happen, + // but if React changes and it becomes an issue, we'd need to rework this. + const store = useMemo(() => { + if (source == null) return disabledActionStore(); + return typeof source === 'function' ? createReactiveActionStore<[], T>(source) : source.reactiveStore(); + }, [source]); + + // Initial dispatch on commit + teardown on store change / unmount. `disabledActionStore` + // returns a store whose `dispatch` and `reset` are no-ops. `store.reset()` aborts the + // in-flight request via the action store's internal controller — so under StrictMode's + // mount → cleanup → mount sequence, the first dispatch is properly aborted before the + // second one fires. + useEffect(() => { + const signal = getAbortSignalRef.current?.(); + if (signal) store.withSignal(signal).dispatch(); + else store.dispatch(); + return () => store.reset(); + }, [store]); + + const refresh = useCallback( + (options?: { abortSignal?: AbortSignal | undefined }) => { + // Presence-based override: an explicit `abortSignal` key (even `undefined`) opts out + // of the factory for this attempt. Omitting the key falls back to the configured + // factory. + const signal = options && 'abortSignal' in options ? options.abortSignal : getAbortSignalRef.current?.(); + if (signal) store.withSignal(signal).dispatch(); + else store.dispatch(); + }, + [store], + ); + + return useRequestResult(store, refresh, source == null); +} diff --git a/packages/react/src/useRequestResult.ts b/packages/react/src/useRequestResult.ts new file mode 100644 index 000000000..25b2d5bb3 --- /dev/null +++ b/packages/react/src/useRequestResult.ts @@ -0,0 +1,48 @@ +import { ReactiveActionStore } from '@solana/kit'; +import { useMemo, useSyncExternalStore } from 'react'; + +import { RequestResult } from './useRequest'; + +/** + * Subscribes to a {@link ReactiveActionStore} and maps its `idle | running | success | error` + * lifecycle onto the {@link RequestResult} shape consumed by `useRequest`. + * + * `idle` is ambiguous on its own: it covers both "no source — store is disabled" and "real source, + * dispatch effect hasn't fired yet on the current render." The `disabled` flag disambiguates: + * disabled → `status: 'disabled'`, enabled → `status: 'fetching'` (the dispatch is about to fire + * on commit; consumers see a single `fetching` paint rather than briefly flashing `disabled`). + * + * - `idle` + disabled → `disabled` + * - `idle` + enabled → `fetching` (first paint, dispatch effect about to commit) + * - `running` → `fetching` (caller inspects `data` / `error` to know if there's stale content) + * - `success` → `success` + * - `error` → `error` + * + * The action store's built-in stale-while-revalidate carries `state.data` and `state.error` + * across attempts, so the bridge doesn't need to mirror them. + * + * @internal + */ +export function useRequestResult( + store: ReactiveActionStore<[], T>, + refresh: (options?: { abortSignal?: AbortSignal | undefined }) => void, + disabled: boolean, +): RequestResult { + const state = useSyncExternalStore(store.subscribe, store.getState, store.getState); + return useMemo(() => { + const status: RequestResult['status'] = + state.status === 'idle' + ? disabled + ? 'disabled' + : 'fetching' + : state.status === 'running' + ? 'fetching' + : state.status; // 'success' | 'error' + return { + data: state.data, + error: state.error, + refresh, + status, + }; + }, [state, refresh, disabled]); +} diff --git a/packages/react/src/useSignIn.ts b/packages/react/src/useSignIn.ts index 5a4e86abe..f02ac47d0 100644 --- a/packages/react/src/useSignIn.ts +++ b/packages/react/src/useSignIn.ts @@ -1,4 +1,4 @@ -import { Address } from '@solana/addresses'; +import { Address } from '@solana/kit'; import { SolanaSignIn, SolanaSignInFeature, diff --git a/packages/react/src/useSubscription.ts b/packages/react/src/useSubscription.ts new file mode 100644 index 000000000..c40723a7c --- /dev/null +++ b/packages/react/src/useSubscription.ts @@ -0,0 +1,152 @@ +import { ReactiveStreamSource } from '@solana/kit'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { disabledStreamStore } from './staticStores'; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; +import { useSubscriptionResult } from './useSubscriptionResult'; + +/** + * Reactive state for a subscription managed by {@link useSubscription} (and other stream-store + * hooks built on top of it). + * + * Lifecycle: starts at `loading` (or `disabled` when the source is `null`) and opens the + * underlying stream on mount; transitions to `loaded` on the first notification or `error` on + * failure. `reconnect()` re-opens the stream — while a reconnect is in flight, `status` returns + * to `loading` and the stale `data` and/or `error` from the prior connection remain populated + * (stale-while-revalidate). + * + * @typeParam T - The notification type emitted by the underlying source. + */ +export type SubscriptionResult = { + /** + * The latest notification. `undefined` on the first load and while disabled. On `loading` + * after a prior outcome, on `error`, and on a subsequent reconnect, holds the last + * received notification. + */ + data: T | undefined; + /** + * Error from the subscription, or `undefined`. On `loading` after a prior `error`, holds the + * stale error so UIs can keep showing the failure context (e.g. a banner) while the + * reconnect is in flight. A subsequent `loaded` clears it. + */ + error: unknown; + /** + * Re-open the stream. By default each call mints a fresh signal from `getAbortSignal` (if + * configured) and threads it through the underlying store's `withSignal(signal).connect()`. + * Pass `{ abortSignal }` to override the configured factory for just this attempt. Pass + * `{ abortSignal: undefined }` to opt out of the factory entirely for this attempt and open + * with no caller-provided signal. + * + * Stable reference. Safe to put in `onClick` handlers or effect deps — typically wired up + * to a "Reconnect" button when `status === 'error'`. Calls `store.connect()` under the + * hood, so it always (re)opens the stream regardless of current status; the bridge + * transitions back through `loading` while preserving stale data and error. + */ + reconnect: (options?: { abortSignal?: AbortSignal | undefined }) => void; + /** + * Lifecycle status as a discriminated string: + * - `loading`: a connection is in progress. On the first connection, `data` and `error` are + * `undefined`. After a reconnect, `data` and `error` hold the last known values from the + * previous connection (stale-while-revalidate). + * - `loaded`: at least one notification has arrived. + * - `error`: the subscription failed; `data` holds the last known value (if any). + * - `disabled`: source was `null` — no subscription was opened. + */ + status: 'disabled' | 'error' | 'loaded' | 'loading'; +}; + +/** Options accepted by {@link useSubscription}. */ +export type UseSubscriptionOptions = { + /** + * Factory invoked on every connection (initial subscribe + every `reconnect()`). The returned + * signal is attached to that connection via the underlying store's + * `withSignal(signal).connect()`, so aborting it tears down that connection. + * + * The most common use is per-connection timeouts: + * `getAbortSignal: () => AbortSignal.timeout(30_000)` gives every connection its own + * 30-second clock that resets on `reconnect()`. + * + * Held in a ref synced to the latest render's closure — there is no need to memoize an inline + * factory. + */ + getAbortSignal?: () => AbortSignal; +}; + +/** + * Subscribe to a stream-store source and surface the latest notification as reactive state. The + * subscription opens on mount, re-opens whenever `source` changes identity, and tears down on + * unmount. + * + * Accepts any {@link ReactiveStreamSource} — the `{ reactiveStore() }` duck-type satisfied by + * `PendingRpcSubscriptionsRequest` (e.g. `client.rpcSubscriptions.accountNotifications(addr)`) + * and any plugin-authored stream object that follows the same convention. Pass `null` to + * disable; the result reports `status: 'disabled'`. + * + * Memoize the source with `useMemo` keyed on whatever inputs it depends on; stable identity is + * how the hook knows when to tear down and re-open. + * + * SSR-safe — on the server the connect effect doesn't run, so the store stays `idle` and the + * hook reports `status: 'loading'`. The first client render hydrates from that same `loading` + * paint, then commits the connect effect. + * + * @typeParam T - The notification type emitted by the source. + * + * @example + * ```tsx + * function AccountBalance({ address }: { address: Address }) { + * const client = useClient>(); + * const source = useMemo(() => client.rpcSubscriptions.accountNotifications(address), [client, address]); + * const { data, error, reconnect } = useSubscription(source); + * if (error) return ; + * return

{data ? `${data.value.lamports} lamports at slot ${data.context.slot}` : 'Connecting…'}

; + * } + * ``` + * + * @see {@link SubscriptionResult} + * @see {@link UseSubscriptionOptions} + */ +export function useSubscription( + source: ReactiveStreamSource | null, + options?: UseSubscriptionOptions, +): SubscriptionResult { + // Ref-sync the per-connection factory so inline closures don't churn the memo below. Each + // connection invokes the latest factory at connect time. + const getAbortSignalRef = useRef(options?.getAbortSignal); + useIsomorphicLayoutEffect(() => { + getAbortSignalRef.current = options?.getAbortSignal; + }); + + // One store per `source`. Both creation paths return an `idle` store; the initial connect + // lives in the effect below so the memo body stays pure (StrictMode's dev double-render, and + // any future render-discard, won't open a subscription from a discarded render). + const store = useMemo(() => { + if (source == null) return disabledStreamStore(); + return source.reactiveStore(); + }, [source]); + + // Initial connect on commit + teardown on store change / unmount. `disabledStreamStore` + // returns a store whose `connect` and `reset` are no-ops, so this branch handles the null + // source case without an explicit gate. `store.reset()` aborts the active connection via + // the action store's internal controller — so under StrictMode's mount → cleanup → mount + // sequence, the first connect is properly aborted before the second one fires. + useEffect(() => { + const signal = getAbortSignalRef.current?.(); + if (signal) store.withSignal(signal).connect(); + else store.connect(); + return () => store.reset(); + }, [store]); + + const reconnect = useCallback( + (options?: { abortSignal?: AbortSignal | undefined }) => { + // Presence-based override: an explicit `abortSignal` key (even `undefined`) opts out + // of the factory for this attempt. Omitting the key falls back to the configured + // factory. + const signal = options && 'abortSignal' in options ? options.abortSignal : getAbortSignalRef.current?.(); + if (signal) store.withSignal(signal).connect(); + else store.connect(); + }, + [store], + ); + + return useSubscriptionResult(store, reconnect, source == null); +} diff --git a/packages/react/src/useSubscriptionResult.ts b/packages/react/src/useSubscriptionResult.ts new file mode 100644 index 000000000..7d18714f6 --- /dev/null +++ b/packages/react/src/useSubscriptionResult.ts @@ -0,0 +1,37 @@ +import { ReactiveStreamStore } from '@solana/kit'; +import { useMemo, useSyncExternalStore } from 'react'; + +import { SubscriptionResult } from './useSubscription'; + +/** + * Subscribes to a {@link ReactiveStreamStore} and maps its + * `idle | loading | loaded | error` lifecycle onto the {@link SubscriptionResult} shape + * consumed by `useSubscription`. The notification passes through unchanged. + * + * For status we map the `idle` state to either `loading` or `disabled`, depending on the + * `disabled` param. This is because `useSubscription` automatically connects, so we treat + * `idle` as `loading` when a source is present and `disabled` when it's not. + * + * Stale-while-revalidate flows naturally through `state.data` / `state.error`, which the store + * preserves across `loading` transitions, so the bridge doesn't need to mirror them. + * + * @param store - The store to subscribe to. + * @param reconnect - A stable callback that re-opens the stream. Forwarded to the result so call + * sites have a single, hook-owned recovery affordance. + * @param disabled - When `true`, the result reports `status: 'disabled'`. Used by + * `useSubscription` to signal the null-source case. + * + * @internal + */ +export function useSubscriptionResult( + store: ReactiveStreamStore, + reconnect: (options?: { abortSignal?: AbortSignal | undefined }) => void, + disabled: boolean, +): SubscriptionResult { + const state = useSyncExternalStore(store.subscribe, store.getUnifiedState, store.getUnifiedState); + return useMemo(() => { + const status: SubscriptionResult['status'] = + state.status === 'idle' ? (disabled ? 'disabled' : 'loading') : state.status; + return { data: state.data, error: state.error, reconnect, status }; + }, [state, reconnect, disabled]); +} diff --git a/packages/react/src/useTrackedData.ts b/packages/react/src/useTrackedData.ts new file mode 100644 index 000000000..d9afaac55 --- /dev/null +++ b/packages/react/src/useTrackedData.ts @@ -0,0 +1,182 @@ +import { + createReactiveStoreWithInitialValueAndSlotTracking, + type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig, + type SolanaRpcResponse, +} from '@solana/kit'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { disabledStreamStore } from './staticStores'; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; +import { useTrackedDataResult } from './useTrackedDataResult'; + +/** + * React-local alias for the Kit primitive's config. Lets the call site name the input shape as + * `TrackedDataSpec` instead of the verbose + * `CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<...>`. + * + * @typeParam TRpcValue - The value inside the initial RPC `SolanaRpcResponse` envelope. + * @typeParam TSubscriptionValue - The value inside subscription `SolanaRpcResponse` notifications. + * @typeParam TItem - The unified item type produced by the two mappers and stored in the result. + */ +export type TrackedDataSpec = + CreateReactiveStoreWithInitialValueAndSlotTrackingConfig; + +/** + * Reactive state for tracked data managed by {@link useTrackedData}. + * + * Lifecycle: starts at `loading` (or `disabled` when the spec is `null`) and fires both the + * initial RPC request and the subscription on mount; transitions to `loaded` on the first + * value (from either source — the underlying store slot-dedupes so out-of-order arrivals never + * regress) or `error` on failure. `refresh()` re-runs the whole pair — while a refresh is in + * flight, `status` returns to `loading` and the stale `data` and/or `error` from the prior + * outcome remain populated (stale-while-revalidate). + * + * @typeParam T - The unified item type held by the store, produced by the two mappers in the + * spec. + */ +export type TrackedDataResult = { + /** + * The latest value, slot-deduped across the initial RPC and the subscription, exactly as the + * underlying kit primitive emits it: a `SolanaRpcResponse` envelope (`{ context: { slot }, + * value }`). The primitive guarantees the envelope shape, so callers can read `data.value` + * and `data.context.slot` directly without a runtime check. `undefined` on the first load + * and while disabled. On `loading` after a prior outcome, on `error`, and on a subsequent + * refresh, holds the last received envelope so UIs can show stale data rather than flashing + * to blank. + */ + data: SolanaRpcResponse | undefined; + /** + * Error from either source, or `undefined`. Only the first error per connection window is + * captured (the underlying store drops subsequent errors until the next `refresh()` / + * connect). On `loading` after a prior `error`, holds the stale error so UIs can keep + * showing the failure context while the refresh is in flight. A subsequent `loaded` clears + * it. + */ + error: unknown; + /** + * Re-run both the initial RPC request and the subscription. By default each call mints a + * fresh signal from `getAbortSignal` (if configured) and threads it through the underlying + * store's `withSignal(signal).connect()`. Pass `{ abortSignal }` to override the configured + * factory for just this attempt. Pass `{ abortSignal: undefined }` to opt out of the + * factory entirely for this attempt and run with no caller-provided signal. + * + * Stable reference. Safe to put in `onClick` handlers or effect deps — typically wired up + * to a "Refresh" or "Retry" button. Calls `store.connect()` under the hood, so it always + * (re)runs the pair regardless of current status; the bridge transitions back through + * `loading` while preserving stale `data` and `error`. + */ + refresh: (options?: { abortSignal?: AbortSignal | undefined }) => void; + /** + * Lifecycle status as a discriminated string: + * - `loading`: an attempt is in progress. On the first attempt, `data` and `error` are + * `undefined`. After a refresh, `data` and `error` hold the last known values from the + * previous attempt (stale-while-revalidate). + * - `loaded`: a value has arrived from either source and `error` is `undefined`. + * - `error`: the attempt failed; `data` holds the last known value (if any). + * - `disabled`: spec was `null` — no work was started. + */ + status: 'disabled' | 'error' | 'loaded' | 'loading'; +}; + +/** Options accepted by {@link useTrackedData}. */ +export type UseTrackedDataOptions = { + /** + * Factory invoked on every attempt (initial run + every `refresh()`). The returned signal is + * attached to that attempt via the underlying store's `withSignal(signal).connect()`, so + * aborting it tears down both the in-flight RPC request and the subscription for that + * attempt. + * + * The most common use is per-attempt timeouts: + * `getAbortSignal: () => AbortSignal.timeout(30_000)` gives every attempt its own + * 30-second clock that resets on `refresh()`. + * + * Held in a ref synced to the latest render's closure — there is no need to memoize an + * inline factory. + */ + getAbortSignal?: () => AbortSignal; +}; + +/** + * Render reactive state 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`) 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. The underlying store slot-dedupes between the + * two sources — out-of-order arrivals never regress the surfaced value. + * + * Pass a memoized {@link TrackedDataSpec} keyed on whatever inputs it depends on; stable identity + * is how the hook knows when to tear down and re-run. Pass `null` to gate the work off — the + * result reports `status: 'disabled'`. + * + * SSR-safe — on the server the connect effect doesn't run, so the store stays `idle` and the + * hook reports `status: 'loading'`. The first client render hydrates from that same `loading` + * paint, then commits the connect effect. + * + * @typeParam TRpcValue - The value inside the initial RPC `SolanaRpcResponse` envelope. + * @typeParam TSubscriptionValue - The value inside subscription `SolanaRpcResponse` notifications. + * @typeParam TItem - The unified item type produced by the two mappers and stored in the result. + * + * @example + * ```tsx + * function AccountBalance({ address }: { address: Address }) { + * const client = useClient & ClientWithRpcSubscriptions>(); + * 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 ; + * return

{data ? `${data.value} lamports at slot ${data.context.slot}` : 'Loading…'}

; + * } + * ``` + * + * @see {@link TrackedDataResult} + * @see {@link UseTrackedDataOptions} + */ +export function useTrackedData( + spec: TrackedDataSpec | null, + options?: UseTrackedDataOptions, +): TrackedDataResult { + // Ref-sync the per-attempt factory so inline closures don't churn the memo below. Each + // attempt invokes the latest factory at connect time. + const getAbortSignalRef = useRef(options?.getAbortSignal); + useIsomorphicLayoutEffect(() => { + getAbortSignalRef.current = options?.getAbortSignal; + }); + + // One store per `spec`. Both creation paths return an `idle` store; the initial connect + // lives in the effect below so the memo body stays pure (StrictMode's dev double-render, and + // any future render-discard, won't fire a network request from a discarded render). + const store = useMemo(() => { + if (spec == null) return disabledStreamStore>(); + return createReactiveStoreWithInitialValueAndSlotTracking(spec); + }, [spec]); + + // Initial connect on commit + teardown on store change / unmount. `disabledStreamStore` + // returns a store whose `connect` and `reset` are no-ops, so this branch handles the null + // spec case without an explicit gate. `store.reset()` aborts the active attempt via the + // store's internal controller — so under StrictMode's mount → cleanup → mount sequence, + // the first connect is properly aborted before the second one fires. + useEffect(() => { + const signal = getAbortSignalRef.current?.(); + if (signal) store.withSignal(signal).connect(); + else store.connect(); + return () => store.reset(); + }, [store]); + + const refresh = useCallback( + (options?: { abortSignal?: AbortSignal | undefined }) => { + // Presence-based override: an explicit `abortSignal` key (even `undefined`) opts out + // of the factory for this attempt. Omitting the key falls back to the configured + // factory. + const signal = options && 'abortSignal' in options ? options.abortSignal : getAbortSignalRef.current?.(); + if (signal) store.withSignal(signal).connect(); + else store.connect(); + }, + [store], + ); + + return useTrackedDataResult(store, refresh, spec == null); +} diff --git a/packages/react/src/useTrackedDataResult.ts b/packages/react/src/useTrackedDataResult.ts new file mode 100644 index 000000000..569788cb1 --- /dev/null +++ b/packages/react/src/useTrackedDataResult.ts @@ -0,0 +1,40 @@ +import type { ReactiveStreamStore, SolanaRpcResponse } from '@solana/kit'; +import { useMemo, useSyncExternalStore } from 'react'; + +import type { TrackedDataResult } from './useTrackedData'; + +/** + * Subscribes to a {@link ReactiveStreamStore} whose value is always shaped as + * `SolanaRpcResponse` (which is what `createReactiveStoreWithInitialValueAndSlotTracking` + * produces) and maps its `idle | loading | loaded | error` lifecycle onto the + * {@link TrackedDataResult} shape consumed by `useTrackedData`. The envelope passes through + * unchanged — callers read `data.value` and `data.context.slot` directly. + * + * `idle` is ambiguous on its own: it covers both "no spec — store is disabled" and "real spec, + * connect effect hasn't fired yet on the current render." The `disabled` flag disambiguates: + * disabled → `status: 'disabled'`, enabled → `status: 'loading'` (the connect is about to fire + * on commit; consumers see a single `loading` paint rather than briefly flashing `disabled`). + * + * Stale-while-revalidate flows naturally through `state.data` / `state.error`, which the store + * preserves across `loading` transitions, so the bridge doesn't need to mirror them. + * + * @param store - The store to subscribe to. + * @param refresh - A stable callback that re-runs the initial RPC and the subscription. + * Forwarded to the result so call sites have a single, hook-owned recovery affordance. + * @param disabled - When `true`, the result reports `status: 'disabled'`. Used by + * `useTrackedData` to signal the null-spec case. + * + * @internal + */ +export function useTrackedDataResult( + store: ReactiveStreamStore>, + refresh: (options?: { abortSignal?: AbortSignal | undefined }) => void, + disabled: boolean, +): TrackedDataResult { + const state = useSyncExternalStore(store.subscribe, store.getUnifiedState, store.getUnifiedState); + return useMemo(() => { + const status: TrackedDataResult['status'] = + state.status === 'idle' ? (disabled ? 'disabled' : 'loading') : state.status; + return { data: state.data, error: state.error, refresh, status }; + }, [state, refresh, disabled]); +} diff --git a/packages/react/src/useWalletAccountMessageSigner.ts b/packages/react/src/useWalletAccountMessageSigner.ts index d4b5b0eca..e5d368a8c 100644 --- a/packages/react/src/useWalletAccountMessageSigner.ts +++ b/packages/react/src/useWalletAccountMessageSigner.ts @@ -1,9 +1,14 @@ -import { Address, address } from '@solana/addresses'; -import { bytesEqual } from '@solana/codecs-core'; -import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors'; -import { SignatureBytes } from '@solana/keys'; +import { + Address, + address, + bytesEqual, + MessageModifyingSigner, + SignableMessage, + SignatureBytes, + SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, + SolanaError, +} from '@solana/kit'; import { getAbortablePromise } from '@solana/promises'; -import { MessageModifyingSigner, SignableMessage } from '@solana/signers'; import type { UiWalletAccount } from '@wallet-standard/ui'; import { useMemo } from 'react'; @@ -21,7 +26,7 @@ import { useSignMessage } from './useSignMessage'; * @example * ```tsx * import { useWalletAccountMessageSigner } from '@solana/react'; - * import { createSignableMessage } from '@solana/signers'; + * import { createSignableMessage } from '@solana/kit'; * * function SignMessageButton({ account, text }) { * const messageSigner = useWalletAccountMessageSigner(account); diff --git a/packages/react/src/useWalletAccountTransactionSendingSigner.ts b/packages/react/src/useWalletAccountTransactionSendingSigner.ts index 57c6eacba..a60a9569b 100644 --- a/packages/react/src/useWalletAccountTransactionSendingSigner.ts +++ b/packages/react/src/useWalletAccountTransactionSendingSigner.ts @@ -1,9 +1,12 @@ -import { address } from '@solana/addresses'; -import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors'; -import { SignatureBytes } from '@solana/keys'; +import { + address, + getTransactionEncoder, + SignatureBytes, + SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, + SolanaError, + TransactionSendingSigner, +} from '@solana/kit'; import { getAbortablePromise } from '@solana/promises'; -import { TransactionSendingSigner } from '@solana/signers'; -import { getTransactionEncoder } from '@solana/transactions'; import { UiWalletAccount } from '@wallet-standard/ui'; import { useMemo, useRef } from 'react'; diff --git a/packages/react/src/useWalletAccountTransactionSigner.ts b/packages/react/src/useWalletAccountTransactionSigner.ts index fcdd55648..eded3586a 100644 --- a/packages/react/src/useWalletAccountTransactionSigner.ts +++ b/packages/react/src/useWalletAccountTransactionSigner.ts @@ -1,17 +1,18 @@ -import { address } from '@solana/addresses'; -import { bytesEqual } from '@solana/codecs-core'; -import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors'; -import { getAbortablePromise } from '@solana/promises'; -import { TransactionModifyingSigner } from '@solana/signers'; -import { getCompiledTransactionMessageDecoder } from '@solana/transaction-messages'; import { + address, assertIsTransactionWithinSizeLimit, + bytesEqual, + getCompiledTransactionMessageDecoder, getTransactionCodec, getTransactionLifetimeConstraintFromCompiledTransactionMessage, + SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, + SolanaError, Transaction, + TransactionModifyingSigner, TransactionWithinSizeLimit, TransactionWithLifetime, -} from '@solana/transactions'; +} from '@solana/kit'; +import { getAbortablePromise } from '@solana/promises'; import { UiWalletAccount } from '@wallet-standard/ui'; import { useMemo, useRef } from 'react'; diff --git a/packages/react/tsconfig.declarations.json b/packages/react/tsconfig.declarations.json index 67ad58e02..6e5c8570f 100644 --- a/packages/react/tsconfig.declarations.json +++ b/packages/react/tsconfig.declarations.json @@ -6,5 +6,5 @@ "outDir": "./dist/types" }, "extends": "./tsconfig.json", - "include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts"] + "include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts", "src/swr.ts"] } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 72f5bc5fc..8cea19167 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "jsx": "react", - "lib": ["DOM", "ES2015", "ESNext.Promise"] + "lib": ["DOM", "ES2015", "ES2022.Object", "ES2024.Promise"] }, "display": "@solana/react", "extends": "../tsconfig/base.json", 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..bd2b393d3 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: attach a + * caller-provided cancellation source per dispatch via + * `store.withSignal(signal).dispatch(...)`. * * @example * ```ts * const store = rpc.getAccountInfo(address).reactiveStore(); + * store.withSignal(AbortSignal.timeout(5_000)).dispatch(); // fire with a per-attempt timeout * const state = useSyncExternalStore(store.subscribe, store.getState); * if (state.status === 'error') return ; * if (state.status === 'running' && !state.data) return ; @@ -61,6 +67,16 @@ export type RpcSendOptions = Readonly<{ abortSignal?: AbortSignal; }>; +/** + * Structural duck-type for anything fireable via `send({ abortSignal })`. Satisfied by + * {@link PendingRpcRequest | PendingRpcRequest}. + * + * @typeParam TResponse - The value `send()` resolves to. + */ +export type RpcSendable = { + send(options?: RpcSendOptions): Promise; +}; + type PendingRpcRequestBuilder = UnionToIntersection< Flatten<{ [P in keyof TMethodImplementations]: PendingRpcRequestReturnTypeMapper; @@ -119,9 +135,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 }); diff --git a/packages/rpc-subscriptions-spec/src/__tests__/rpc-subscriptions-test.ts b/packages/rpc-subscriptions-spec/src/__tests__/rpc-subscriptions-test.ts index e7e661cd1..824ad2735 100644 --- a/packages/rpc-subscriptions-spec/src/__tests__/rpc-subscriptions-test.ts +++ b/packages/rpc-subscriptions-spec/src/__tests__/rpc-subscriptions-test.ts @@ -34,74 +34,6 @@ describe('createSubscriptionRpc', () => { }); }); -describe('PendingRpcSubscriptionsRequest.reactive()', () => { - let mockTransport: jest.MockedFunction; - let mockOn: jest.Mock; - let mockDataPublisher: DataPublisher; - let rpcSubscriptions: RpcSubscriptions; - function publish(type: string, payload: unknown) { - mockOn.mock.calls.filter(([actualType]) => actualType === type).forEach(([_, listener]) => listener(payload)); - } - beforeEach(() => { - mockOn = jest.fn().mockReturnValue(function unsubscribe() {}); - mockDataPublisher = { on: mockOn }; - mockTransport = jest.fn().mockResolvedValue(mockDataPublisher); - rpcSubscriptions = createSubscriptionRpc({ - api: { - thingNotifications(...args: unknown[]) { - return { - execute: jest.fn().mockResolvedValue(mockDataPublisher), - request: { methodName: 'thingNotifications', params: args }, - }; - }, - }, - transport: mockTransport, - }); - }); - - it('passes the abort signal to the transport', async () => { - expect.assertions(1); - const abortController = new AbortController(); - await rpcSubscriptions.thingNotifications().reactive({ abortSignal: abortController.signal }); - expect(mockTransport).toHaveBeenCalledWith(expect.objectContaining({ signal: abortController.signal })); - }); - it('returns a store whose getState() starts as undefined', async () => { - expect.assertions(1); - const store = await rpcSubscriptions - .thingNotifications() - .reactive({ abortSignal: new AbortController().signal }); - expect(store.getState()).toBeUndefined(); - }); - it('returns a store whose getState() reflects incoming notifications', async () => { - expect.assertions(1); - const store = await rpcSubscriptions - .thingNotifications() - .reactive({ abortSignal: new AbortController().signal }); - publish('notification', { value: 42 }); - expect(store.getState()).toStrictEqual({ value: 42 }); - }); - it('calls store subscribers when a notification arrives', async () => { - expect.assertions(1); - const store = await rpcSubscriptions - .thingNotifications() - .reactive({ abortSignal: new AbortController().signal }); - const subscriber = jest.fn(); - store.subscribe(subscriber); - publish('notification', { value: 42 }); - expect(subscriber).toHaveBeenCalledTimes(1); - }); - it('surfaces errors via getError()', async () => { - expect.assertions(2); - const store = await rpcSubscriptions - .thingNotifications() - .reactive({ abortSignal: new AbortController().signal }); - expect(store.getError()).toBeUndefined(); - const error = new Error('o no'); - publish('error', error); - expect(store.getError()).toBe(error); - }); -}); - describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => { let mockTransport: jest.MockedFunction; let mockOn: jest.Mock; @@ -133,28 +65,38 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => { }); }); - it('passes the abort signal to the transport', async () => { + it('returns a store that starts in `idle` status and does not call the transport before connect()', () => { + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + expect(store.getUnifiedState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + expect(mockTransport).not.toHaveBeenCalled(); + }); + it('passes the per-connection signal to the transport on withSignal().connect()', async () => { expect.assertions(1); const abortController = new AbortController(); - rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.withSignal(abortController.signal).connect(); await flushMicrotasks(); - expect(mockTransport).toHaveBeenCalledWith(expect.objectContaining({ signal: abortController.signal })); + const transportSignal = mockTransport.mock.calls[0][0].signal; + // The transport receives a composed signal — aborting the caller's source aborts it. + expect(transportSignal.aborted).toBe(false); }); - it('returns a store that starts in `loading` status before the transport resolves', () => { - const store = rpcSubscriptions - .thingNotifications() - .reactiveStore({ abortSignal: new AbortController().signal }); + it('transitions to `loading` after connect() before the transport resolves', () => { + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.connect(); expect(store.getUnifiedState()).toStrictEqual({ data: undefined, error: undefined, status: 'loading', }); }); - it('returns a store whose state reflects incoming notifications', async () => { + it('reflects incoming notifications after connect()', async () => { expect.assertions(1); - const store = rpcSubscriptions - .thingNotifications() - .reactiveStore({ abortSignal: new AbortController().signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.connect(); await flushMicrotasks(); publish('notification', { value: 42 }); expect(store.getUnifiedState()).toStrictEqual({ @@ -165,9 +107,8 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => { }); it('calls store subscribers when a notification arrives', async () => { expect.assertions(1); - const store = rpcSubscriptions - .thingNotifications() - .reactiveStore({ abortSignal: new AbortController().signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.connect(); await flushMicrotasks(); const subscriber = jest.fn(); store.subscribe(subscriber); @@ -176,9 +117,8 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => { }); it('surfaces errors via getUnifiedState()', async () => { expect.assertions(1); - const store = rpcSubscriptions - .thingNotifications() - .reactiveStore({ abortSignal: new AbortController().signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.connect(); await flushMicrotasks(); const error = new Error('o no'); publish('error', error); @@ -188,21 +128,21 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => { status: 'error', }); }); - it('re-invokes the transport on retry() after an error', async () => { + it('re-invokes the transport on connect() after an error', async () => { expect.assertions(1); - const store = rpcSubscriptions - .thingNotifications() - .reactiveStore({ abortSignal: new AbortController().signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.connect(); await flushMicrotasks(); publish('error', new Error('stream died')); - store.retry(); + store.connect(); await flushMicrotasks(); expect(mockTransport).toHaveBeenCalledTimes(2); }); - it('aborts the signal forwarded to the data publisher listeners when the caller aborts', async () => { + it('aborts the signal forwarded to the data publisher listeners when the caller signal aborts', async () => { expect.assertions(2); const abortController = new AbortController(); - rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.withSignal(abortController.signal).connect(); await flushMicrotasks(); const onCall = mockOn.mock.calls.find(([channel]: [string]) => channel === 'notification'); const listenerSignal = (onCall![2] as { signal: AbortSignal }).signal; @@ -212,9 +152,8 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => { }); it('returns the same getUnifiedState() snapshot across consecutive calls when state has not changed', async () => { expect.assertions(2); - const store = rpcSubscriptions - .thingNotifications() - .reactiveStore({ abortSignal: new AbortController().signal }); + const store = rpcSubscriptions.thingNotifications().reactiveStore(); + store.connect(); await flushMicrotasks(); expect(store.getUnifiedState()).toBe(store.getUnifiedState()); publish('notification', { value: 42 }); diff --git a/packages/rpc-subscriptions-spec/src/rpc-subscriptions-request.ts b/packages/rpc-subscriptions-spec/src/rpc-subscriptions-request.ts index 8c5bfb647..461836d9b 100644 --- a/packages/rpc-subscriptions-spec/src/rpc-subscriptions-request.ts +++ b/packages/rpc-subscriptions-spec/src/rpc-subscriptions-request.ts @@ -9,49 +9,35 @@ import { ReactiveStreamStore } from '@solana/subscribable'; * {@link PendingRpcSubscriptionsRequest | PendingRpcSubscriptionsRequest} will * trigger the subscription and return a promise for an async iterable that vends `TNotifications`. * - * Calling the {@link PendingRpcSubscriptionsRequest.reactiveStore | `reactiveStore(options)`} - * method will return a {@link ReactiveStreamStore} compatible with `useSyncExternalStore`, Svelte - * stores, and other reactive primitives. + * Calling the {@link PendingRpcSubscriptionsRequest.reactiveStore | `reactiveStore()`} method + * will return a {@link ReactiveStreamStore} compatible with `useSyncExternalStore`, Svelte + * stores, and other reactive primitives. The returned store is in `status: 'idle'`; the caller + * is responsible for invoking {@link ReactiveStreamStore.connect | `connect()`} to open the + * underlying stream. Attach a caller-provided cancellation source via + * {@link ReactiveStreamStore.withSignal | `withSignal()`}. */ export type PendingRpcSubscriptionsRequest = { /** - * Triggers the subscription and returns a promise for a {@link ReactiveStreamStore} that holds - * the latest notification. Compatible with `useSyncExternalStore` and other reactive primitives - * that expect a `{ subscribe, getState }` contract. + * Synchronously returns a {@link ReactiveStreamStore} that holds the latest notification. + * Compatible with `useSyncExternalStore` and other reactive primitives that expect a + * `{ subscribe, getUnifiedState }` contract. The returned store is in `status: 'idle'` — call + * {@link ReactiveStreamStore.connect | `connect()`} to open the subscription; a follow-up + * `connect()` after an error reopens it. Attach a caller-provided cancellation source via + * {@link ReactiveStreamStore.withSignal | `withSignal()`}. * * @example * ```ts - * const store = await rpc.accountNotifications(address).reactive({ abortSignal }); - * // React — throw error from snapshot to surface via Error Boundary - * const state = useSyncExternalStore(store.subscribe, () => { - * if (store.getError()) throw store.getError(); - * return store.getState(); - * }); - * ``` - * - * @deprecated Use {@link PendingRpcSubscriptionsRequest.reactiveStore | `reactiveStore()`} - * instead. The synchronous variant returns a store that reconnects on - * {@link ReactiveStreamStore.retry | `retry()`} after an error, whereas the store returned by - * `reactive()` cannot recover once its underlying `DataPublisher` has failed. - */ - reactive(options: RpcSubscribeOptions): Promise>; - /** - * Synchronously returns a {@link ReactiveStreamStore} that subscribes in the background and - * holds the latest notification. Compatible with `useSyncExternalStore` and other reactive - * primitives that expect a `{ subscribe, getUnifiedState }` contract. The store opens a fresh - * subscription on construction and on every {@link ReactiveStreamStore.retry | `retry()`}. - * - * @example - * ```ts - * const store = rpc.accountNotifications(address).reactiveStore({ abortSignal }); + * const store = rpc.accountNotifications(address).reactiveStore(); + * // Per-connection timeout — fresh clock per attempt: + * store.withSignal(AbortSignal.timeout(30_000)).connect(); * // React — the unified snapshot has stable identity per update. * const state = useSyncExternalStore(store.subscribe, store.getUnifiedState); - * if (state.status === 'error') return ; - * if (state.status === 'loading') return ; + * if (state.status === 'error') return ; + * if (state.status === 'loading' || state.status === 'idle') return ; * return ; * ``` */ - reactiveStore(options: RpcSubscribeOptions): ReactiveStreamStore; + reactiveStore(): ReactiveStreamStore; /** * Triggers the subscription and returns a promise for an async iterable of notifications. * Use `for await...of` to consume notifications as they arrive. Abort the signal to @@ -72,3 +58,13 @@ export type RpcSubscribeOptions = Readonly<{ /** An `AbortSignal` to fire when you want to unsubscribe */ abortSignal: AbortSignal; }>; + +/** + * Structural duck-type for anything subscribable via `subscribe({ abortSignal })`. Satisfied by + * {@link PendingRpcSubscriptionsRequest | PendingRpcSubscriptionsRequest}. + * + * @typeParam TNotification - The notification type yielded by the returned async iterable. + */ +export type RpcSubscribable = { + subscribe(options: RpcSubscribeOptions): Promise>; +}; diff --git a/packages/rpc-subscriptions-spec/src/rpc-subscriptions.ts b/packages/rpc-subscriptions-spec/src/rpc-subscriptions.ts index f5cff6775..2c8e47a29 100644 --- a/packages/rpc-subscriptions-spec/src/rpc-subscriptions.ts +++ b/packages/rpc-subscriptions-spec/src/rpc-subscriptions.ts @@ -2,7 +2,6 @@ import { SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_PLAN, Solan import { Callable, Flatten, OverloadImplementations, UnionToIntersection } from '@solana/rpc-spec-types'; import { createAsyncIterableFromDataPublisher, - createReactiveStoreFromDataPublisher, createReactiveStoreFromDataPublisherFactory, } from '@solana/subscribable'; @@ -83,23 +82,10 @@ function createPendingRpcSubscription( subscriptionsPlan: RpcSubscriptionsPlan, ): PendingRpcSubscriptionsRequest { return { - async reactive({ abortSignal }: RpcSubscribeOptions) { - const notificationsDataPublisher = await transport({ - signal: abortSignal, - ...subscriptionsPlan, - }); - return createReactiveStoreFromDataPublisher({ - abortSignal, - dataChannelName: 'notification', - dataPublisher: notificationsDataPublisher, - errorChannelName: 'error', - }); - }, - reactiveStore({ abortSignal }: RpcSubscribeOptions) { + reactiveStore() { return createReactiveStoreFromDataPublisherFactory({ - abortSignal, - createDataPublisher() { - return transport({ signal: abortSignal, ...subscriptionsPlan }); + createDataPublisher(signal) { + return transport({ signal, ...subscriptionsPlan }); }, dataChannelName: 'notification', errorChannelName: 'error', diff --git a/packages/rpc-types/README.md b/packages/rpc-types/README.md index 8161b32f4..62abe49f5 100644 --- a/packages/rpc-types/README.md +++ b/packages/rpc-types/README.md @@ -35,6 +35,22 @@ This type represents a number which has been encoded as a string for transit ove This type represents a Unix timestamp in _seconds_. It is represented as a `bigint` in client code and an `i64` in server code. +### `UnwrapRpcResponse` + +A conditional type that unwraps `SolanaRpcResponse` → `U` at the type level so callers can surface the inner value without losing static type information. Values that are not wrapped in a `SolanaRpcResponse` envelope pass through unchanged. + +```ts +import type { SolanaRpcResponse, UnwrapRpcResponse } from '@solana/rpc-types'; + +type AccountValue = UnwrapRpcResponse>; +// ^? { lamports: bigint } + +type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>; +// ^? { lamports: bigint } +``` + +Pairs with [`isSolanaRpcResponse()`](#issolanarpcresponse) for runtime detection. + ## Functions ### `assertIsLamports()` @@ -140,6 +156,23 @@ import { lamports } from '@solana/rpc-types'; await transfer(address(fromAddress), address(toAddress), lamports(100000n)); ``` +### `isSolanaRpcResponse()` + +Type-guards a notification as a `SolanaRpcResponse` envelope. Validates `context.slot: bigint` and the presence of `value`, so adding new fields to the envelope in the future doesn't change the guard's contract — only the load-bearing fields are checked. The narrowed type is `SolanaRpcResponse>`, so callers don't need to spell out the inner type separately. + +```ts +import { isSolanaRpcResponse, type SolanaRpcResponse } from '@solana/rpc-types'; + +function lift(notification: T) { + if (isSolanaRpcResponse(notification)) { + return { slot: notification.context.slot, value: notification.value }; + } + return { slot: undefined, value: notification }; +} +``` + +Pairs with [`UnwrapRpcResponse`](#unwraprpcresponset) for the type-level counterpart. + ### `stringifiedBigInt()` This helper combines _asserting_ that a string represents a `bigint` with _coercing_ it to the `StringifiedBigInt` type. It's best used with untrusted input. diff --git a/packages/rpc-types/src/__tests__/rpc-api-test.ts b/packages/rpc-types/src/__tests__/rpc-api-test.ts new file mode 100644 index 000000000..789b4fb18 --- /dev/null +++ b/packages/rpc-types/src/__tests__/rpc-api-test.ts @@ -0,0 +1,59 @@ +import { isSolanaRpcResponse, type SolanaRpcResponse } from '../rpc-api'; +import type { Slot } from '../typed-numbers'; + +describe('isSolanaRpcResponse', () => { + it('returns true for a well-formed envelope', () => { + const envelope: SolanaRpcResponse<{ lamports: bigint }> = { + context: { slot: 99n as Slot }, + value: { lamports: 5n }, + }; + expect(isSolanaRpcResponse(envelope)).toBe(true); + }); + + it('accepts envelopes whose value is `undefined` or `null`', () => { + expect(isSolanaRpcResponse({ context: { slot: 7n }, value: undefined })).toBe(true); + expect(isSolanaRpcResponse({ context: { slot: 8n }, value: null })).toBe(true); + }); + + it('ignores extra fields on `context` (future-proof against new envelope fields)', () => { + expect(isSolanaRpcResponse({ context: { apiVersion: '2.0', slot: 1n }, value: 42 })).toBe(true); + }); + + it('returns false for raw notifications without an envelope', () => { + expect(isSolanaRpcResponse({ parent: 9n, root: 8n, slot: 10n })).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isSolanaRpcResponse(42)).toBe(false); + expect(isSolanaRpcResponse('hello')).toBe(false); + expect(isSolanaRpcResponse(true)).toBe(false); + }); + + it('returns false for nullish input', () => { + expect(isSolanaRpcResponse(undefined)).toBe(false); + expect(isSolanaRpcResponse(null)).toBe(false); + }); + + it('returns false when `value` is missing', () => { + expect(isSolanaRpcResponse({ context: { slot: 1n } })).toBe(false); + }); + + it('returns false when `context` is missing', () => { + expect(isSolanaRpcResponse({ value: 42 })).toBe(false); + }); + + it('returns false when `context` is not an object', () => { + expect(isSolanaRpcResponse({ context: 'oops', value: 42 })).toBe(false); + expect(isSolanaRpcResponse({ context: null, value: 42 })).toBe(false); + }); + + it('returns false when `context.slot` is missing', () => { + expect(isSolanaRpcResponse({ context: { apiVersion: '2.0' }, value: 42 })).toBe(false); + }); + + it('returns false when `context.slot` is not a bigint', () => { + expect(isSolanaRpcResponse({ context: { slot: 1 }, value: 42 })).toBe(false); + expect(isSolanaRpcResponse({ context: { slot: '1' }, value: 42 })).toBe(false); + expect(isSolanaRpcResponse({ context: { slot: null }, value: 42 })).toBe(false); + }); +}); diff --git a/packages/rpc-types/src/__typetests__/rpc-api-typetest.ts b/packages/rpc-types/src/__typetests__/rpc-api-typetest.ts new file mode 100644 index 000000000..0680bf038 --- /dev/null +++ b/packages/rpc-types/src/__typetests__/rpc-api-typetest.ts @@ -0,0 +1,60 @@ +import { isSolanaRpcResponse, type SolanaRpcResponse, type UnwrapRpcResponse } from '../rpc-api'; +import type { Slot } from '../typed-numbers'; + +// [DESCRIBE] UnwrapRpcResponse +{ + // Unwraps `SolanaRpcResponse` to `U` + null as unknown as UnwrapRpcResponse> satisfies { lamports: bigint }; + + // Non-envelope types pass through unchanged + null as unknown as UnwrapRpcResponse<{ lamports: bigint }> satisfies { lamports: bigint }; + null as unknown as UnwrapRpcResponse satisfies number; + null as unknown as UnwrapRpcResponse satisfies string; +} + +// [DESCRIBE] isSolanaRpcResponse +{ + // Narrows an envelope-typed value to the same envelope shape. + const envelope = null as unknown as SolanaRpcResponse<{ lamports: bigint }>; + if (isSolanaRpcResponse(envelope)) { + envelope.context.slot satisfies Slot; + envelope.value satisfies { lamports: bigint }; + } + + // For a raw notification, the true-branch narrows to `SolanaRpcResponse`. + // (Reaching the true branch on a raw value is impossible at runtime — the guard + // validates the envelope shape — so the imprecision is benign.) + const raw = null as unknown as { parent: bigint; slot: bigint }; + if (isSolanaRpcResponse(raw)) { + raw.context.slot satisfies Slot; + raw.value satisfies { parent: bigint; slot: bigint }; + } + + // For a union of envelope and raw, the true branch narrows to an envelope wrapping + // either inner type. + const mixed = null as unknown as SolanaRpcResponse<{ lamports: bigint }> | { lamports: bigint }; + if (isSolanaRpcResponse(mixed)) { + mixed.context.slot satisfies Slot; + mixed.value satisfies { lamports: bigint }; + } else { + mixed satisfies { lamports: bigint }; + } + + // `unknown` input narrows to `SolanaRpcResponse` in the true branch. + const u = null as unknown; + if (isSolanaRpcResponse(u)) { + u.context.slot satisfies Slot; + u.value satisfies unknown; + } + + // Generic-T call-site: the narrowing must work when invoked from inside a generic function + // with a parameter typed as `T | undefined`. This mirrors how `useSubscription` consumes + // the guard against a `ReactiveStreamStore`'s current value. + function generic(value: T | undefined): { slot: Slot | undefined; value: UnwrapRpcResponse | undefined } { + if (isSolanaRpcResponse(value)) { + return { slot: value.context.slot, value: value.value }; + } + return { slot: undefined, value: value as UnwrapRpcResponse | undefined }; + } + void generic; +} diff --git a/packages/rpc-types/src/rpc-api.ts b/packages/rpc-types/src/rpc-api.ts index 1a5b87097..92b212c22 100644 --- a/packages/rpc-types/src/rpc-api.ts +++ b/packages/rpc-types/src/rpc-api.ts @@ -4,3 +4,56 @@ export type SolanaRpcResponse = Readonly<{ context: Readonly<{ slot: Slot }>; value: TValue; }>; + +/** + * Unwraps `SolanaRpcResponse` → `U` at the type level so callers can surface + * the inner value without losing static type information. Values that are not + * wrapped in a `SolanaRpcResponse` envelope pass through unchanged. + * + * Pairs with {@link isSolanaRpcResponse} for runtime detection. + * + * @typeParam T - The raw notification shape. + * + * @example + * ```ts + * type AccountValue = UnwrapRpcResponse>; + * // ^? { lamports: bigint } + * + * type AccountValue = UnwrapRpcResponse<{ lamports: bigint }>; + * // ^? { lamports: bigint } + * ``` + */ +export type UnwrapRpcResponse = T extends SolanaRpcResponse ? U : T; + +/** + * Type-guards a notification as a {@link SolanaRpcResponse} envelope. Validates the shape by + * duck-typing for `context.slot: bigint` and the presence of `value`. + * + * The narrowed type is `SolanaRpcResponse>`. In the false branch, + * `notification` retains its original type. + * + * @typeParam T - The notification shape, which may be a raw value, an envelope, or a union of the two. + * @param notification - The value to test. + * @return `true` when `notification` is a `SolanaRpcResponse` envelope, narrowing accordingly. + * + * @example + * ```ts + * if (isSolanaRpcResponse(notification)) { + * return { slot: notification.context.slot, value: notification.value }; + * } + * ``` + */ +export function isSolanaRpcResponse( + notification: SolanaRpcResponse> | T, +): notification is SolanaRpcResponse> { + return ( + notification != null && + typeof notification === 'object' && + 'context' in notification && + notification.context != null && + typeof notification.context === 'object' && + 'slot' in notification.context && + typeof notification.context.slot === 'bigint' && + 'value' in notification + ); +} diff --git a/packages/subscribable/README.md b/packages/subscribable/README.md index c7f192189..6c70e76a6 100644 --- a/packages/subscribable/README.md +++ b/packages/subscribable/README.md @@ -35,31 +35,43 @@ This type represents a reactive store that holds the latest value published to a ```ts type ReactiveState = - | { data: undefined; error: undefined; status: 'loading' } + | { data: T | undefined; error: unknown; status: 'loading' } | { data: T; error: undefined; status: 'loaded' } | { data: T | undefined; error: unknown; status: 'error' } - | { data: T | undefined; error: undefined; status: 'retrying' }; + | { data: undefined; error: undefined; status: 'idle' }; ``` > Also exported as `ReactiveStore` for backwards compatibility. That alias is deprecated and will be removed in a future major release. -```ts +The store starts in `status: 'idle'`. Call `connect()` to open the underlying stream; the store will transition through `loading` → `loaded` (or `error`). Every subsequent `connect()` transitions back through `loading`, preserving the last known `data` and `error` (stale-while-revalidate). A subsequent `loaded` clears the error; a subsequent `error` replaces it. Call `reset()` to tear down the connection and return to `idle` without permanently killing the store. + +```tsx const store: ReactiveStreamStore = /* ... */; // React — snapshot identity is stable between updates, so it can be passed directly. const state = useSyncExternalStore(store.subscribe, store.getUnifiedState); -if (state.status === 'error') return ; -if (state.status === 'loading') return ; -return ; +useEffect(() => { + store.connect(); + return () => store.reset(); +}, [store]); +// Stale-while-revalidate: keep showing the last value while a reconnect is in flight. +return ( + <> + {state.data !== undefined && } + {state.status === 'loading' && state.data === undefined && } + {state.status === 'error' && } + +); // Vue const snapshot = shallowRef(store.getUnifiedState()); store.subscribe(() => { snapshot.value = store.getUnifiedState(); }); +store.connect(); ``` -`retry()` re-opens the stream after an error. When the underlying store supports restart (see [`createReactiveStoreFromDataPublisherFactory`](#createreactivestorefromdatapublisherfactory-abortsignal-createdatapublisher-datachannelname-errorchannelname-)), the store transitions to `status: 'retrying'` and reconnects. Stores that cannot be restarted throw a `SolanaError` with code `SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED` instead. +`retry()` is a deprecated alias for the error-recovery case — prefer calling `connect()` directly. `connect()` always reopens the stream, regardless of current status; wrap with a status guard at the call site if you need the error-only behaviour. The individual `getState()` and `getError()` getters on `ReactiveStreamStore` are `@deprecated` — prefer `getUnifiedState()`, which exposes the same information with a stable snapshot identity and `status` discriminator. @@ -72,12 +84,12 @@ The snapshot is a discriminated union: ```ts type ReactiveActionState = | { status: 'idle'; data: undefined; error: undefined } - | { status: 'running'; data: TResult | undefined; error: undefined } + | { status: 'running'; data: TResult | undefined; error: unknown } | { status: 'success'; data: TResult; error: undefined } | { status: 'error'; data: TResult | undefined; error: unknown }; ``` -`data` is the last successful result and survives across transitions — a `running` or `error` snapshot still carries the last value so UIs can render stale content while a retry is in flight. Only `reset()` clears it. +`data` is the last successful result and `error` is the last failure; both survive across transitions so UIs can render stale content (data **or** error) while a retry is in flight. `success` clears `error`; only `reset()` clears `data`. Unlike `ReactiveStreamStore` (which models a stream of values with a separate error channel), `ReactiveActionStore` models a one-shot-per-dispatch lifecycle where errors are part of the snapshot. @@ -134,6 +146,22 @@ Things to note: - Two ways to trigger the action: - `dispatch(...)` — fire-and-forget. Returns `undefined` synchronously and never throws; safe to call from UI event handlers without a `.catch`. Failures surface on state as `{ status: 'error' }`. - `dispatchAsync(...)` — returns a promise that resolves to the wrapped function's result. Rejects on failure and with an `AbortError` when superseded or `reset()`. Use from imperative code that needs the resolved value; pair with [`isAbortError`](../promises#isaborterrorerr) from `@solana/promises` to filter abort rejections. +- Attach a caller-provided `AbortSignal` to a `dispatch` or `dispatchAsync` call via `store.withSignal(signal)`: + + ```ts + // Per-attempt timeout — fresh signal per call: + store.withSignal(AbortSignal.timeout(5_000)).dispatch(someAccountId); + + // Shared kill switch — bind the wrapper once, reuse everywhere: + const killCtrl = new AbortController(); + const killable = store.withSignal(killCtrl.signal); + killable.dispatch(someAccountId); + killable.dispatch(someAccountId); + killCtrl.abort(); // cancels in-flight and short-circuits future calls + ``` + + The wrapped 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 wrapper exposes only `dispatch` / `dispatchAsync` — `getState` / `subscribe` / `reset` stay on the parent store. + - Calling either dispatch while one is in flight aborts the previous call; its outcome is dropped from state regardless of which variant started it. - `data` survives across transitions: a fresh `running` or `error` snapshot carries the last successful result so call sites can keep rendering stale content while a retry is in flight. Only `reset()` clears it. - `reset()` aborts the in-flight dispatch and restores the idle snapshot, clearing both `data` and `error`. @@ -171,55 +199,32 @@ Things to note: - If there are messages in the queue and the abort signal fires, all queued messages will be vended to the iterator after which it will return. - Any new iterators created after the first error is encountered will reject with that error when polled. -### `createReactiveStoreFromDataPublisher({ abortSignal, dataChannelName, dataPublisher, errorChannelName })` - -> **Deprecated.** Prefer [`createReactiveStoreFromDataPublisherFactory`](#createreactivestorefromdatapublisherfactory-abortsignal-createdatapublisher-datachannelname-errorchannelname-) — it supports `retry()`. Because this function accepts a ready-made `DataPublisher` rather than a factory, it cannot restart the underlying source, and calling `retry()` on the returned store throws a `SolanaError` with code `SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED`. - -Returns a `ReactiveStreamStore` given a data publisher. The store holds the most recent message published to `dataChannelName` and notifies subscribers on each update. When a message is published to `errorChannelName`, the store transitions to `status: 'error'` preserving the last known value. Triggering the abort signal disconnects the store from the data publisher. - -```ts -const store = createReactiveStoreFromDataPublisher({ - abortSignal: AbortSignal.timeout(10_000), - dataChannelName: 'notification', - dataPublisher, - errorChannelName: 'error', -}); -const unsubscribe = store.subscribe(() => { - console.log('State updated:', store.getUnifiedState()); -}); -``` - -Things to note: - -- `getUnifiedState()` starts in `status: 'loading'` until the first notification arrives. -- On error, `status` becomes `'error'` with the last known value preserved on `data`. Only the first error is captured. -- The function returned by `subscribe` is idempotent — calling it multiple times is safe. +### `createReactiveStoreFromDataPublisherFactory({ createDataPublisher, dataChannelName, errorChannelName })` -### `createReactiveStoreFromDataPublisherFactory({ abortSignal, createDataPublisher, dataChannelName, errorChannelName })` - -Returns a `ReactiveStreamStore` that wires itself to a fresh `DataPublisher` on construction and on every `retry()`. Unlike `createReactiveStoreFromDataPublisher`, this variant accepts an async factory so the store can tear down a broken stream and open a new one without losing subscribers or the last known value. +Returns a `ReactiveStreamStore` that wires itself to a fresh `DataPublisher` on every `connect()`. Accepts an async factory so the store can tear down a broken stream and open a new one without losing subscribers or the last known value. The factory receives the per-connection `AbortSignal` so the underlying transport can stop when the connection window closes. ```ts const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: AbortSignal.timeout(60_000), - async createDataPublisher() { - return await openMyConnection(); - }, - dataChannelName: 'notification', + createDataPublisher: signal => getDataPublisherFromEventEmitter(new WebSocket(url, { signal })), + dataChannelName: 'message', errorChannelName: 'error', }); -store.subscribe(() => { +const unsubscribe = store.subscribe(() => { const snapshot = store.getUnifiedState(); - if (snapshot.status === 'error') store.retry(); + if (snapshot.status === 'error') console.error('Connection failed:', snapshot.error); + else if (snapshot.status === 'loaded') console.log('Latest:', snapshot.data); }); +// Fresh 30-second clock per connection attempt: +store.withSignal(AbortSignal.timeout(30_000)).connect(); ``` Things to note: -- `createDataPublisher` is called once on construction and again on every `retry()`. -- `retry()` is a no-op unless the store is in `status: 'error'`; otherwise the store transitions to `status: 'retrying'` (preserving stale data) and reconnects. -- If `createDataPublisher` rejects, the store transitions to `status: 'error'` with the rejection as the error. Call `retry()` to try again. -- Triggering the caller's `abortSignal` disconnects the store permanently; subsequent `retry()` calls are no-ops. +- The returned store starts in `status: 'idle'`. Call `connect()` to open the first stream. +- `createDataPublisher` is invoked on every `connect()`. The store transitions through `loading`, preserving the last known `data` and `error` (stale-while-revalidate). +- If `createDataPublisher` rejects, the store transitions to `status: 'error'` with the rejection as the error. Call `connect()` to try again. +- `reset()` aborts the current connection and returns the store to `idle`, clearing `data` and `error`. A follow-up `connect()` opens a fresh stream. +- Attach a caller-provided cancellation source via `store.withSignal(signal).connect()` — the signal is composed with the per-connection controller via `AbortSignal.any`. Aborting the caller's signal transitions the store to `error` with that abort reason. ### `demultiplexDataPublisher(publisher, sourceChannelName, messageTransformer)` diff --git a/packages/subscribable/src/__tests__/reactive-action-store-test.ts b/packages/subscribable/src/__tests__/reactive-action-store-test.ts index 531ca380e..38616806e 100644 --- a/packages/subscribable/src/__tests__/reactive-action-store-test.ts +++ b/packages/subscribable/src/__tests__/reactive-action-store-test.ts @@ -174,6 +174,107 @@ describe('createReactiveActionStore', () => { }); }); + describe('withSignal()', () => { + it('forwards a non-aborted internal signal to `fn` when called via the bare `dispatch`', async () => { + expect.assertions(1); + const fn = jest.fn((_signal: AbortSignal) => Promise.resolve('ok')); + const store = createReactiveActionStore(fn); + await store.dispatchAsync(); + expect(fn.mock.calls[0][0].aborted).toBe(false); + }); + + it('aborts the in-flight dispatch and transitions to `error` when the caller-provided signal fires', async () => { + expect.assertions(2); + const ctrl = new AbortController(); + const reason = new Error('per-request abort'); + const store = createReactiveActionStore(() => new Promise(() => {})); + const dispatched = store.withSignal(ctrl.signal).dispatchAsync(); + ctrl.abort(reason); + await expect(dispatched).rejects.toBe(reason); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: reason, + status: 'error', + }); + }); + + it('preserves stale data when the caller-provided signal aborts after a prior success', async () => { + expect.assertions(1); + const ctrl = new AbortController(); + const reason = new Error('abort'); + const { promise: second } = Promise.withResolvers(); + const results = [Promise.resolve('first'), second]; + const store = createReactiveActionStore(() => results.shift()!); + await store.dispatchAsync(); + const dispatched = store.withSignal(ctrl.signal).dispatchAsync(); + ctrl.abort(reason); + await dispatched.catch(() => {}); + expect(store.getState()).toStrictEqual({ + data: 'first', + error: reason, + status: 'error', + }); + }); + + it('lets later dispatches recover when a prior call started with an already-aborted signal', async () => { + expect.assertions(2); + const store = createReactiveActionStore(() => Promise.resolve('ok')); + await store + .withSignal(AbortSignal.abort(new Error('first'))) + .dispatchAsync() + .catch(() => {}); + expect(store.getState().status).toBe('error'); + await store.dispatchAsync(); + expect(store.getState()).toStrictEqual({ + data: 'ok', + error: undefined, + status: 'success', + }); + }); + + it('passes a combined signal to `fn` that fires when the caller-provided signal aborts', async () => { + expect.assertions(1); + const ctrl = new AbortController(); + let captured: AbortSignal | undefined; + const store = createReactiveActionStore((signal: AbortSignal) => { + captured = signal; + return new Promise(() => {}); + }); + store.withSignal(ctrl.signal).dispatch(); + ctrl.abort(new Error('boom')); + await Promise.resolve(); + expect(captured?.aborted).toBe(true); + }); + + it('lets the caller vary the signal across dispatches on the same store', async () => { + expect.assertions(2); + const fn = jest.fn((_signal: AbortSignal) => Promise.resolve('ok')); + const store = createReactiveActionStore(fn); + const ctrlA = new AbortController(); + const ctrlB = new AbortController(); + await store.withSignal(ctrlA.signal).dispatchAsync(); + await store.withSignal(ctrlB.signal).dispatchAsync(); + // Aborting one controller only fires its own dispatch's composed signal. + ctrlA.abort(new Error('only-A')); + expect(fn.mock.calls[0][0].aborted).toBe(true); + expect(fn.mock.calls[1][0].aborted).toBe(false); + }); + + it('lets a wrapper be reused as a "kill switch" across dispatches', async () => { + expect.assertions(2); + const killCtrl = new AbortController(); + const fn = jest.fn((_signal: AbortSignal) => Promise.resolve('ok')); + const store = createReactiveActionStore(fn); + const killable = store.withSignal(killCtrl.signal); + await killable.dispatchAsync(); + await killable.dispatchAsync(); + killCtrl.abort(new Error('killed')); + // Both completed dispatches saw a signal that's now aborted. + expect(fn.mock.calls[0][0].aborted).toBe(true); + expect(fn.mock.calls[1][0].aborted).toBe(true); + }); + }); + describe('reset()', () => { it('returns the store to idle from a success state', async () => { expect.assertions(1); @@ -345,6 +446,37 @@ describe('createReactiveActionStore', () => { resolveSecond('second'); }); + it('preserves the last `error` across a subsequent `running` state', async () => { + expect.assertions(1); + const failure = new Error('boom'); + const { promise: second } = Promise.withResolvers(); + const results = [Promise.reject(failure), second]; + const store = createReactiveActionStore(() => results.shift()!); + await store.dispatchAsync().catch(() => {}); + store.dispatch(); + expect(store.getState()).toStrictEqual({ + data: undefined, + error: failure, + status: 'running', + }); + }); + + it('preserves both stale `data` and stale `error` across a subsequent `running` state', async () => { + expect.assertions(1); + const failure = new Error('boom'); + const { promise: third } = Promise.withResolvers(); + const results = [Promise.resolve('first'), Promise.reject(failure), third]; + const store = createReactiveActionStore(() => results.shift()!); + await store.dispatchAsync(); + await store.dispatchAsync().catch(() => {}); + store.dispatch(); + expect(store.getState()).toStrictEqual({ + data: 'first', + error: failure, + status: 'running', + }); + }); + it('preserves the last successful `data` across a subsequent `error` state', async () => { expect.assertions(1); const failure = new Error('boom'); diff --git a/packages/subscribable/src/__tests__/reactive-stream-store-test.ts b/packages/subscribable/src/__tests__/reactive-stream-store-test.ts index 08db3e00d..03c0d84e5 100644 --- a/packages/subscribable/src/__tests__/reactive-stream-store-test.ts +++ b/packages/subscribable/src/__tests__/reactive-stream-store-test.ts @@ -1,353 +1,8 @@ -import { SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED, SolanaError } from '@solana/errors'; - import { DataPublisher } from '../data-publisher'; -import { - createReactiveStoreFromDataPublisher, - createReactiveStoreFromDataPublisherFactory, -} from '../reactive-stream-store'; +import { createReactiveStoreFromDataPublisherFactory } from '../reactive-stream-store'; jest.useFakeTimers(); -describe('createReactiveStoreFromDataPublisher', () => { - let mockDataPublisher: DataPublisher; - let mockOn: jest.Mock; - function publish(type: string, payload: unknown) { - mockOn.mock.calls.filter(([actualType]) => actualType === type).forEach(([_, listener]) => listener(payload)); - } - beforeEach(() => { - mockOn = jest.fn().mockReturnValue(function unsubscribe() {}); - mockDataPublisher = { - on: mockOn, - }; - }); - - describe('getState()', () => { - it('returns `undefined` before any notification arrives', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - expect(store.getState()).toBeUndefined(); - }); - it('returns the latest notification after one arrives', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - publish('data', { value: 42 }); - expect(store.getState()).toStrictEqual({ value: 42 }); - }); - it('returns the most recent notification when multiple arrive', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - publish('data', { value: 1 }); - publish('data', { value: 2 }); - publish('data', { value: 3 }); - expect(store.getState()).toStrictEqual({ value: 3 }); - }); - it('preserves the last known value after an error', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - publish('data', { value: 42 }); - publish('error', new Error('o no')); - expect(store.getState()).toStrictEqual({ value: 42 }); - }); - it('returns `undefined` after an error when no notification has arrived', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - publish('error', new Error('o no')); - expect(store.getState()).toBeUndefined(); - }); - }); - - describe('getError()', () => { - it('returns `undefined` before any error', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - expect(store.getError()).toBeUndefined(); - }); - it('returns the error after one arrives', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const error = new Error('o no'); - publish('error', error); - expect(store.getError()).toBe(error); - }); - it('preserves the first error when multiple errors arrive', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const firstError = new Error('first'); - const secondError = new Error('second'); - publish('error', firstError); - publish('error', secondError); - expect(store.getError()).toBe(firstError); - }); - it('remains `undefined` when only data notifications arrive', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - publish('data', { value: 1 }); - publish('data', { value: 2 }); - expect(store.getError()).toBeUndefined(); - }); - }); - - describe('getUnifiedState()', () => { - it('starts in `loading` status with no data or error', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - expect(store.getUnifiedState()).toStrictEqual({ - data: undefined, - error: undefined, - status: 'loading', - }); - }); - it('transitions to `loaded` with the value when a notification arrives', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - publish('data', { value: 42 }); - expect(store.getUnifiedState()).toStrictEqual({ - data: { value: 42 }, - error: undefined, - status: 'loaded', - }); - }); - it('transitions to `error` preserving the last known value', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const error = new Error('o no'); - publish('data', { value: 42 }); - publish('error', error); - expect(store.getUnifiedState()).toStrictEqual({ - data: { value: 42 }, - error, - status: 'error', - }); - }); - it('transitions to `error` with undefined data when no value arrived first', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const error = new Error('o no'); - publish('error', error); - expect(store.getUnifiedState()).toStrictEqual({ - data: undefined, - error, - status: 'error', - }); - }); - }); - - describe('retry()', () => { - it('throws a SolanaError because a raw DataPublisher cannot be restarted', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - expect(() => store.retry()).toThrow(new SolanaError(SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED)); - }); - }); - - describe('subscribe()', () => { - it('calls the subscriber when a notification arrives', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriber = jest.fn(); - store.subscribe(subscriber); - publish('data', { value: 1 }); - expect(subscriber).toHaveBeenCalledTimes(1); - }); - it('calls the subscriber on each new notification', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriber = jest.fn(); - store.subscribe(subscriber); - publish('data', { value: 1 }); - publish('data', { value: 2 }); - publish('data', { value: 3 }); - expect(subscriber).toHaveBeenCalledTimes(3); - }); - it('calls the subscriber when an error arrives', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriber = jest.fn(); - store.subscribe(subscriber); - publish('error', new Error('o no')); - expect(subscriber).toHaveBeenCalledTimes(1); - }); - it('does not notify subscribers on subsequent errors', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriber = jest.fn(); - store.subscribe(subscriber); - publish('error', new Error('first')); - publish('error', new Error('second')); - expect(subscriber).toHaveBeenCalledTimes(1); - }); - it('calls multiple concurrent subscribers on each notification', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriberA = jest.fn(); - const subscriberB = jest.fn(); - store.subscribe(subscriberA); - store.subscribe(subscriberB); - publish('data', { value: 1 }); - expect(subscriberA).toHaveBeenCalledTimes(1); - expect(subscriberB).toHaveBeenCalledTimes(1); - }); - it('stops calling the subscriber after the returned unsubscribe is called', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriber = jest.fn(); - const unsubscribe = store.subscribe(subscriber); - unsubscribe(); - publish('data', { value: 1 }); - expect(subscriber).not.toHaveBeenCalled(); - }); - it('only unsubscribes the subscriber whose unsubscribe function was called', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const subscriberA = jest.fn(); - const subscriberB = jest.fn(); - const unsubscribeA = store.subscribe(subscriberA); - store.subscribe(subscriberB); - unsubscribeA(); - publish('data', { value: 1 }); - expect(subscriberA).not.toHaveBeenCalled(); - expect(subscriberB).toHaveBeenCalledTimes(1); - }); - it('the unsubscribe function is idempotent', () => { - const store = createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const unsubscribe = store.subscribe(jest.fn()); - expect(() => { - unsubscribe(); - unsubscribe(); - }).not.toThrow(); - }); - }); - - describe('abort signal', () => { - it('aborts the signals forwarded to dataPublisher.on() when the caller aborts', () => { - const abortController = new AbortController(); - createReactiveStoreFromDataPublisher({ - abortSignal: abortController.signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const dataChannelSignal = mockOn.mock.calls.find(([type]: [string]) => type === 'data')![2].signal; - const errorChannelSignal = mockOn.mock.calls.find(([type]: [string]) => type === 'error')![2].signal; - expect(dataChannelSignal.aborted).toBe(false); - expect(errorChannelSignal.aborted).toBe(false); - const reason = new Error('go away'); - abortController.abort(reason); - expect(dataChannelSignal.aborted).toBe(true); - expect(dataChannelSignal.reason).toBe(reason); - expect(errorChannelSignal.aborted).toBe(true); - expect(errorChannelSignal.reason).toBe(reason); - }); - it('aborts the signals forwarded to dataPublisher.on() when an error arrives', () => { - createReactiveStoreFromDataPublisher({ - abortSignal: new AbortController().signal, - dataChannelName: 'data', - dataPublisher: mockDataPublisher, - errorChannelName: 'error', - }); - const dataChannelSignal = mockOn.mock.calls.find(([type]: [string]) => type === 'data')![2].signal; - const errorChannelSignal = mockOn.mock.calls.find(([type]: [string]) => type === 'error')![2].signal; - expect(dataChannelSignal.aborted).toBe(false); - expect(errorChannelSignal.aborted).toBe(false); - const error = new Error('o no'); - publish('error', error); - expect(dataChannelSignal.aborted).toBe(true); - expect(dataChannelSignal.reason).toBe(error); - expect(errorChannelSignal.aborted).toBe(true); - expect(errorChannelSignal.reason).toBe(error); - }); - }); -}); - describe('createReactiveStoreFromDataPublisherFactory', () => { function createMockDataPublisher(): { mockOn: jest.Mock; @@ -381,30 +36,48 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { return { mockRequest, publishers }; } - describe('initial connection', () => { - it('starts in `loading` before the factory resolves', () => { + describe('initial state', () => { + it('starts in `idle` status and does not invoke the factory before connect()', () => { const { mockRequest } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + expect(store.getUnifiedState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); + expect(mockRequest).not.toHaveBeenCalled(); + }); + }); + + describe('connect()', () => { + it('transitions from idle to `loading` and invokes the factory', () => { + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); expect(store.getUnifiedState()).toStrictEqual({ data: undefined, error: undefined, status: 'loading', }); + expect(mockRequest).toHaveBeenCalledTimes(1); }); it('transitions to `loaded` once the factory resolves and data arrives', async () => { expect.assertions(1); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); publishers[0].publish('data', { value: 42 }); expect(store.getUnifiedState()).toStrictEqual({ @@ -418,11 +91,11 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { const failure = new Error('connection refused'); const mockRequest = jest.fn().mockRejectedValue(failure); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); expect(store.getUnifiedState()).toStrictEqual({ data: undefined, @@ -434,11 +107,11 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { expect.assertions(1); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); publishers[0].publish('data', { value: 42 }); const failure = new Error('stream died'); @@ -449,69 +122,70 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { status: 'error', }); }); - }); - - describe('retry()', () => { - it('is a no-op when the store is not in `error` state', async () => { - expect.assertions(2); - const { mockRequest } = createFactory(); + it('from `error`, transitions back through `loading` preserving stale data AND error (SWR)', async () => { + expect.assertions(1); + const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); - const callsBefore = mockRequest.mock.calls.length; - store.retry(); - expect(mockRequest).toHaveBeenCalledTimes(callsBefore); - expect(store.getUnifiedState().status).toBe('loading'); + publishers[0].publish('data', { value: 42 }); + const fail = new Error('fail'); + publishers[0].publish('error', fail); + store.connect(); + expect(store.getUnifiedState()).toStrictEqual({ + data: { value: 42 }, + error: fail, + status: 'loading', + }); }); - it('transitions to `retrying` and preserves stale data', async () => { + it('from `loaded`, transitions back through `loading` preserving the last value', async () => { expect.assertions(1); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); publishers[0].publish('data', { value: 42 }); - publishers[0].publish('error', new Error('fail')); - store.retry(); + store.connect(); expect(store.getUnifiedState()).toStrictEqual({ data: { value: 42 }, error: undefined, - status: 'retrying', + status: 'loading', }); }); - it('invokes the factory a second time', async () => { + it('invokes the factory again on each connect()', async () => { expect.assertions(1); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); publishers[0].publish('error', new Error('fail')); - store.retry(); + store.connect(); expect(mockRequest).toHaveBeenCalledTimes(2); }); - it('transitions back to `loaded` when the retried stream publishes a value', async () => { + it('transitions back to `loaded` when the reconnected stream publishes a value', async () => { expect.assertions(1); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); publishers[0].publish('error', new Error('fail')); - store.retry(); + store.connect(); await jest.runAllTimersAsync(); publishers[1].publish('data', { value: 'recovered' }); expect(store.getUnifiedState()).toStrictEqual({ @@ -520,23 +194,23 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { status: 'loaded', }); }); - it('notifies subscribers on the retrying transition', async () => { + it('notifies subscribers on the loaded → loading transition after reconnect', async () => { expect.assertions(1); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); publishers[0].publish('error', new Error('fail')); const subscriber = jest.fn(); store.subscribe(subscriber); - store.retry(); + store.connect(); expect(subscriber).toHaveBeenCalledTimes(1); }); - it('can recover from a factory-rejection error by retrying', async () => { + it('can recover from a factory-rejection error by calling connect() again', async () => { expect.assertions(2); const publisher = createMockDataPublisher(); const mockRequest = jest @@ -544,14 +218,14 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { .mockRejectedValueOnce(new Error('transient')) .mockResolvedValue(publisher.publisher); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); expect(store.getUnifiedState().status).toBe('error'); - store.retry(); + store.connect(); await jest.runAllTimersAsync(); publisher.publish('data', { value: 99 }); expect(store.getUnifiedState()).toStrictEqual({ @@ -560,19 +234,19 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { status: 'loaded', }); }); - it('transitions back to `error` when the retried factory rejects again', async () => { + it('transitions back to `error` when the reconnected factory rejects again', async () => { expect.assertions(1); const firstFailure = new Error('first'); const secondFailure = new Error('second'); const mockRequest = jest.fn().mockRejectedValueOnce(firstFailure).mockRejectedValue(secondFailure); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: new AbortController().signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); - store.retry(); + store.connect(); await jest.runAllTimersAsync(); expect(store.getUnifiedState()).toStrictEqual({ data: undefined, @@ -580,99 +254,335 @@ describe('createReactiveStoreFromDataPublisherFactory', () => { status: 'error', }); }); + it('stays in `loading` when called again before the first connection resolves', () => { + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); + store.connect(); + expect(store.getUnifiedState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'loading', + }); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + it('does not notify subscribers on the loading → loading re-entry', () => { + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); + const subscriber = jest.fn(); + store.subscribe(subscriber); + store.connect(); + expect(subscriber).not.toHaveBeenCalled(); + }); + it('aborts the prior connection when called again before data arrives', async () => { + expect.assertions(2); + const { mockRequest, publishers } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); + await jest.runAllTimersAsync(); + const firstSignal = publishers[0].mockOn.mock.calls.find(([channel]: [string]) => channel === 'data')![2] + .signal; + expect(firstSignal.aborted).toBe(false); + store.connect(); + expect(firstSignal.aborted).toBe(true); + }); }); - describe('abort signal', () => { - it('prevents further state updates once the caller aborts', async () => { + describe('reset()', () => { + it('returns to `idle` and clears prior data', async () => { expect.assertions(1); - const abortController = new AbortController(); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: abortController.signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); - abortController.abort(); - publishers[0].publish('data', { value: 'late' }); - expect(store.getUnifiedState().status).toBe('loading'); + publishers[0].publish('data', { value: 42 }); + store.reset(); + expect(store.getUnifiedState()).toStrictEqual({ + data: undefined, + error: undefined, + status: 'idle', + }); }); - it('aborts the signal forwarded to the inner DataPublisher listeners', async () => { + it('aborts the in-flight connection', async () => { expect.assertions(2); - const abortController = new AbortController(); const { mockRequest, publishers } = createFactory(); - createReactiveStoreFromDataPublisherFactory({ - abortSignal: abortController.signal, + const store = createReactiveStoreFromDataPublisherFactory({ createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); - const dataSignal = publishers[0].mockOn.mock.calls.find(([channel]: [string]) => channel === 'data')![2] + const listenerSignal = publishers[0].mockOn.mock.calls.find(([channel]: [string]) => channel === 'data')![2] .signal; - expect(dataSignal.aborted).toBe(false); - abortController.abort(); - expect(dataSignal.aborted).toBe(true); + expect(listenerSignal.aborted).toBe(false); + store.reset(); + expect(listenerSignal.aborted).toBe(true); }); - it('retry() after abort does not re-invoke the factory', async () => { + it('notifies subscribers when state changes from non-idle', async () => { expect.assertions(1); - const abortController = new AbortController(); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: abortController.signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); + await jest.runAllTimersAsync(); + publishers[0].publish('data', { value: 42 }); + const subscriber = jest.fn(); + store.subscribe(subscriber); + store.reset(); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + it('is a no-op when already idle (subscribers not notified)', () => { + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + const subscriber = jest.fn(); + store.subscribe(subscriber); + store.reset(); + expect(subscriber).not.toHaveBeenCalled(); + }); + it('allows a follow-up connect() to open a fresh stream', async () => { + expect.assertions(2); + const { mockRequest, publishers } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); + await jest.runAllTimersAsync(); + store.reset(); + store.connect(); + await jest.runAllTimersAsync(); + publishers[1].publish('data', { value: 'fresh' }); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(store.getUnifiedState()).toStrictEqual({ + data: { value: 'fresh' }, + error: undefined, + status: 'loaded', + }); + }); + }); + + describe('retry() (deprecated)', () => { + it('is a no-op when the store is not in `error` state', async () => { + expect.assertions(2); + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); await jest.runAllTimersAsync(); - publishers[0].publish('error', new Error('fail')); - abortController.abort(); const callsBefore = mockRequest.mock.calls.length; store.retry(); - await jest.runAllTimersAsync(); expect(mockRequest).toHaveBeenCalledTimes(callsBefore); + expect(store.getUnifiedState().status).toBe('loading'); }); - it('retry() after abort leaves the store in `error` state', async () => { + it('transitions back to `loading` from `error`, preserving stale data and error (SWR)', async () => { expect.assertions(1); - const abortController = new AbortController(); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: abortController.signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.connect(); await jest.runAllTimersAsync(); - const failure = new Error('fail'); - publishers[0].publish('error', failure); - abortController.abort(); + publishers[0].publish('data', { value: 42 }); + const fail = new Error('fail'); + publishers[0].publish('error', fail); store.retry(); + expect(store.getUnifiedState()).toStrictEqual({ + data: { value: 42 }, + error: fail, + status: 'loading', + }); + }); + }); + + describe('subscribe()', () => { + it('stops calling the subscriber after the returned unsubscribe is called', async () => { + expect.assertions(1); + const { mockRequest, publishers } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.connect(); + await jest.runAllTimersAsync(); + const subscriber = jest.fn(); + const unsubscribe = store.subscribe(subscriber); + unsubscribe(); + publishers[0].publish('data', { value: 1 }); + expect(subscriber).not.toHaveBeenCalled(); + }); + it('the unsubscribe function is idempotent', () => { + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + const unsubscribe = store.subscribe(jest.fn()); + expect(() => { + unsubscribe(); + unsubscribe(); + }).not.toThrow(); + }); + }); + + describe('withSignal()', () => { + it('forwards a non-aborted composed signal to the data publisher listeners', async () => { + expect.assertions(1); + const abortController = new AbortController(); + const { mockRequest, publishers } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.withSignal(abortController.signal).connect(); await jest.runAllTimersAsync(); + const dataSignal = publishers[0].mockOn.mock.calls.find(([channel]: [string]) => channel === 'data')![2] + .signal; + expect(dataSignal.aborted).toBe(false); + }); + it('aborts the listener signal when the caller signal aborts', async () => { + expect.assertions(2); + const abortController = new AbortController(); + const { mockRequest, publishers } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.withSignal(abortController.signal).connect(); + await jest.runAllTimersAsync(); + const dataSignal = publishers[0].mockOn.mock.calls.find(([channel]: [string]) => channel === 'data')![2] + .signal; + expect(dataSignal.aborted).toBe(false); + abortController.abort(); + expect(dataSignal.aborted).toBe(true); + }); + it('transitions the store to `error` with the caller signal abort reason', () => { + const abortController = new AbortController(); + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.withSignal(abortController.signal).connect(); + const reason = new Error('timed out'); + abortController.abort(reason); expect(store.getUnifiedState()).toStrictEqual({ data: undefined, - error: failure, + error: reason, status: 'error', }); }); - it('retry() after abort does not notify subscribers', async () => { + it('preserves prior data when the caller signal aborts mid-stream', async () => { expect.assertions(1); const abortController = new AbortController(); const { mockRequest, publishers } = createFactory(); const store = createReactiveStoreFromDataPublisherFactory({ - abortSignal: abortController.signal, createDataPublisher: mockRequest, dataChannelName: 'data', errorChannelName: 'error', }); + store.withSignal(abortController.signal).connect(); await jest.runAllTimersAsync(); - publishers[0].publish('error', new Error('fail')); - abortController.abort(); - const subscriber = jest.fn(); - store.subscribe(subscriber); - store.retry(); + publishers[0].publish('data', { value: 42 }); + const reason = new Error('timed out'); + abortController.abort(reason); + expect(store.getUnifiedState()).toStrictEqual({ + data: { value: 42 }, + error: reason, + status: 'error', + }); + }); + it('when the caller signal is already aborted, transitions to error without invoking the factory', () => { + const abortController = new AbortController(); + const reason = new Error('pre-aborted'); + abortController.abort(reason); + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.withSignal(abortController.signal).connect(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(store.getUnifiedState()).toStrictEqual({ + data: undefined, + error: reason, + status: 'error', + }); + }); + it('a bound wrapper reused across calls — kill-switch pattern', async () => { + expect.assertions(3); + const killController = new AbortController(); + const { mockRequest, publishers } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + const killable = store.withSignal(killController.signal); + killable.connect(); await jest.runAllTimersAsync(); - expect(subscriber).not.toHaveBeenCalled(); + publishers[0].publish('data', { value: 'first' }); + // Re-connect via the same wrapper. + killable.connect(); + await jest.runAllTimersAsync(); + expect(mockRequest).toHaveBeenCalledTimes(2); + // Once killed, subsequent calls short-circuit to error. + const reason = new Error('kill'); + killController.abort(reason); + killable.connect(); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(store.getUnifiedState().status).toBe('error'); + }); + it('does not abort the listener signal on supersede via a fresh connect()', async () => { + expect.assertions(1); + const abortController = new AbortController(); + const { mockRequest } = createFactory(); + const store = createReactiveStoreFromDataPublisherFactory({ + createDataPublisher: mockRequest, + dataChannelName: 'data', + errorChannelName: 'error', + }); + store.withSignal(abortController.signal).connect(); + await jest.runAllTimersAsync(); + // Bare connect() aborts the prior inner connection but leaves the caller signal alone. + store.connect(); + expect(abortController.signal.aborted).toBe(false); }); }); }); diff --git a/packages/subscribable/src/reactive-action-store.ts b/packages/subscribable/src/reactive-action-store.ts index cc3a8b8a6..70ccdf554 100644 --- a/packages/subscribable/src/reactive-action-store.ts +++ b/packages/subscribable/src/reactive-action-store.ts @@ -7,13 +7,13 @@ export type ReactiveActionStatus = 'error' | 'idle' | 'running' | 'success'; /** * Discriminated state of a {@link ReactiveActionStore}, keyed by {@link ReactiveActionStatus}. * - * `data` holds the most recent successful result and persists through subsequent `running` and - * `error` states so call sites can keep rendering stale content while a retry is in flight. Only - * `reset()` clears it. + * `data` holds the most recent successful result and `error` holds the most recent failure. Both + * persist through subsequent `running` states so call sites can keep rendering stale content + * while a retry is in flight. `success` clears `error`; only `reset()` clears `data`. */ export type ReactiveActionState = - | { readonly data: TResult | undefined; readonly error: undefined; readonly status: 'running' } | { readonly data: TResult | undefined; readonly error: unknown; readonly status: 'error' } + | { readonly data: TResult | undefined; readonly error: unknown; readonly status: 'running' } | { readonly data: TResult; readonly error: undefined; readonly status: 'success' } | { readonly data: undefined; readonly error: undefined; readonly status: 'idle' }; @@ -31,6 +31,7 @@ export type ReactiveActionStore = { * no state update. Use from UI event handlers; there's no promise to handle or `.catch`. * * @see {@link ReactiveActionStore.dispatchAsync} when you need the resolved value or propagated errors. + * @see {@link ReactiveActionStore.withSignal} to attach a caller-provided `AbortSignal` to a dispatch. */ readonly dispatch: (...args: TArgs) => void; /** @@ -46,20 +47,49 @@ export type ReactiveActionStore = { readonly reset: () => void; /** Registers a listener called on every state change. Returns an unsubscribe function. */ readonly subscribe: (listener: () => void) => () => void; + /** + * Returns a thin wrapper exposing `dispatch` / `dispatchAsync` that compose `signal` with the + * store's internal per-dispatch controller via `AbortSignal.any` — aborting either cancels + * the in-flight call. Aborting the caller-provided signal surfaces the abort reason on state + * as `{ status: 'error' }`; the internal controller path (supersession by a newer dispatch or + * `reset()`) is silent by design so the newer dispatch owns state. Use this to attach a + * caller-provided cancellation source (per-attempt timeout, shared kill switch, parent-context + * signal) without touching the bare `dispatch` / `dispatchAsync` API. + * + * - Per-attempt timeout: `store.withSignal(AbortSignal.timeout(5_000)).dispatch(args)` — fresh + * clock per call. + * - Permanent kill switch: hold one `AbortController`, bind the wrapper once + * (`const killable = store.withSignal(killCtrl.signal)`), and use `killable.dispatch(...)` + * everywhere; aborting the controller cancels in-flight and short-circuits future calls. + * + * The wrapper exposes only `dispatch` / `dispatchAsync` — `getState` / `subscribe` / `reset` + * remain store-level concerns on the parent. + */ + readonly withSignal: (signal: AbortSignal) => { + readonly dispatch: (...args: TArgs) => void; + readonly dispatchAsync: (...args: TArgs) => Promise; + }; }; /** - * Duck-type for objects that build a {@link ReactiveActionStore} on demand via a zero-argument - * `reactiveStore()` method. Satisfied by `PendingRpcRequest`. The `[]` argument tuple is - * intentional — the operation's arguments are already baked into the pending request, so each - * `dispatch()` re-fires the same call. + * Duck-type for objects that build a {@link ReactiveActionStore} on demand via `reactiveStore()`. + * Satisfied by `PendingRpcRequest`. The `[]` argument tuple is intentional — the operation's + * arguments are already baked into the pending request, so each `dispatch()` re-fires the same + * call. + * + * The returned store is in the `idle` state — the caller is responsible for calling `dispatch()` + * to fire the first attempt. Attach a caller-provided cancellation source per dispatch via + * `store.withSignal(signal).dispatch(...)` — see {@link ReactiveActionStore.withSignal}. * * @typeParam T - The value type resolved by the wrapped operation. * * @example * ```ts * function bind(source: ReactiveActionSource) { - * return source.reactiveStore(); + * const store = source.reactiveStore(); + * // Per-attempt timeout, fresh signal per call: + * store.withSignal(AbortSignal.timeout(30_000)).dispatch(); + * return store; * } * ``` * @@ -82,13 +112,16 @@ const IDLE_STATE: ReactiveActionState = Object.freeze({ * so only the most recent dispatch can mutate state. * * The wrapped function receives the `AbortSignal` as its first argument, followed by whatever - * arguments were passed to `dispatch`. + * arguments were passed to `dispatch`. Callers attach their own cancellation source per-call via + * {@link ReactiveActionStore.withSignal} — `store.withSignal(signal).dispatch(...)`. The caller's + * signal is composed with the per-dispatch controller via `AbortSignal.any`, so aborting it + * cancels the in-flight call and surfaces the abort reason on state. * * @typeParam TArgs - Argument tuple forwarded from `dispatch` to `fn`. * @typeParam TResult - Resolved value type of `fn`. * @param fn - Async function to wrap. Receives an {@link AbortSignal} plus the dispatch arguments. * @return A {@link ReactiveActionStore} exposing `dispatch`, `dispatchAsync`, `getState`, `subscribe`, - * and `reset`. + * `reset`, and `withSignal`. * * @example * ```ts @@ -100,7 +133,10 @@ const IDLE_STATE: ReactiveActionState = Object.freeze({ * store.subscribe(() => console.log(store.getState())); * store.dispatch(someAccountId); // fire-and-forget; state is the source of truth * - * // Or, when you need the resolved value imperatively: + * // Per-attempt timeout — fresh signal per call: + * store.withSignal(AbortSignal.timeout(30_000)).dispatch(someAccountId); + * + * // Imperative call with the resolved value: * const account = await store.dispatchAsync(someAccountId); * ``` * @@ -121,13 +157,14 @@ export function createReactiveActionStore listener()); } - const dispatchAsync = async (...args: TArgs): Promise => { + const dispatchAsyncWithSignal = async (userSignal: AbortSignal | undefined, ...args: TArgs): Promise => { currentController?.abort(); const controller = new AbortController(); currentController = controller; - const { signal } = controller; + const signal = userSignal ? AbortSignal.any([controller.signal, userSignal]) : controller.signal; const previousData = state.data; - setState({ data: previousData, error: undefined, status: 'running' }); + const previousError = state.error; + setState({ data: previousData, error: previousError, status: 'running' }); try { const result = await getAbortablePromise(fn(signal, ...args), signal); if (signal.aborted) { @@ -136,14 +173,19 @@ export function createReactiveActionStore => dispatchAsyncWithSignal(undefined, ...args); const dispatch = (...args: TArgs): void => { dispatchAsync(...args).catch(() => {}); }; @@ -163,5 +205,11 @@ export function createReactiveActionStore ({ + dispatch: (...args: TArgs): void => { + dispatchAsyncWithSignal(signal, ...args).catch(() => {}); + }, + dispatchAsync: (...args: TArgs): Promise => dispatchAsyncWithSignal(signal, ...args), + }), }; } diff --git a/packages/subscribable/src/reactive-stream-store.ts b/packages/subscribable/src/reactive-stream-store.ts index 2c62ca28c..9e3fdd35e 100644 --- a/packages/subscribable/src/reactive-stream-store.ts +++ b/packages/subscribable/src/reactive-stream-store.ts @@ -1,43 +1,24 @@ -import { SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED, SolanaError } from '@solana/errors'; import { AbortController } from '@solana/event-target-impl'; import { DataPublisher } from './data-publisher'; -type Config = Readonly<{ - /** - * Triggering this abort signal will cause the store to stop updating and will disconnect it from - * the underlying data publisher. - */ - abortSignal: AbortSignal; - /** - * Messages from this channel of `dataPublisher` will be used to update the store's state. - */ - dataChannelName: string; - // FIXME: It would be nice to be able to constrain the type of `dataPublisher` to one that +type FactoryConfig = Readonly<{ + // FIXME: It would be nice to be able to constrain the type returned by `createDataPublisher` to one that // definitely supports the `dataChannelName` and `errorChannelName` channels, and // furthermore publishes `TData` on the `dataChannelName` channel. This is more difficult // than it should be: https://tsplay.dev/NlZelW - dataPublisher: DataPublisher; - /** - * Messages from this channel of `dataPublisher` will cause subscribers to be notified without - * updating the state, so that they can respond to the error condition. - */ - errorChannelName: string; -}>; - -type FactoryConfig = Readonly<{ - /** - * Triggering this abort signal will cause the store to stop updating and will disconnect it from - * any active {@link DataPublisher}. Subsequent calls to {@link ReactiveStreamStore.retry | `retry()`} - * are no-ops once this signal has fired. - */ - abortSignal: AbortSignal; /** * An async factory that produces a fresh {@link DataPublisher} each time it is invoked. Called - * once on construction and again on every {@link ReactiveStreamStore.retry | `retry()`}. Rejections - * surface as a store error. + * on every {@link ReactiveStreamStore.connect | `connect()`}. + * + * Receives an {@link AbortSignal} that fires when this specific connection window should tear + * down — composed from the per-connection inner controller and (if attached via + * {@link ReactiveStreamStore.withSignal | `withSignal()`}) the caller-provided signal via + * `AbortSignal.any`. Thread it into the underlying transport's own cancellation so the + * connection itself stops on per-connection abort, not just the stream-store's listeners. + * Rejections surface as a store error. */ - createDataPublisher: () => Promise; + createDataPublisher: (signal: AbortSignal) => Promise; /** * Messages from this channel of the produced `DataPublisher` will be used to update the store's * state. @@ -53,23 +34,26 @@ type FactoryConfig = Readonly<{ /** * The lifecycle state of a {@link ReactiveStreamStore} as a single snapshot. * - * - `loading`: the store is waiting for its first value. `data` is `undefined`. - * - `loaded`: a value has been received and no error is active. `data` is defined. - * - `error`: the stream failed. `data` is the last known value (or `undefined` if no value ever - * arrived), and `error` holds the failure. - * - `retrying`: a {@link ReactiveStreamStore.retry | `retry()`} is in progress after a previous error. - * `error` is cleared; `data` is preserved from before the failure if present. + * - `idle`: the store has not yet been connected, or has been reset via + * {@link ReactiveStreamStore.reset | `reset()`}. Call + * {@link ReactiveStreamStore.connect | `connect()`} to open the underlying stream. + * - `loading`: a connection is in progress. `data` and `error` are preserved from the previous + * connection (if any) — stale-while-revalidate UX. A subsequent `loaded` clears `error`; a + * subsequent `error` replaces it. + * - `loaded`: a value has been received and no error is active. + * - `error`: the stream failed. `data` holds the last known value (or `undefined` if none ever + * arrived) and `error` holds the failure. */ export type ReactiveState = - | { readonly data: T | undefined; readonly error: undefined; readonly status: 'retrying' } | { readonly data: T | undefined; readonly error: unknown; readonly status: 'error' } + | { readonly data: T | undefined; readonly error: unknown; readonly status: 'loading' } | { readonly data: T; readonly error: undefined; readonly status: 'loaded' } - | { readonly data: undefined; readonly error: undefined; readonly status: 'loading' }; + | { readonly data: undefined; readonly error: undefined; readonly status: 'idle' }; -const LOADING_STATE: ReactiveState = Object.freeze({ +const IDLE_STATE: ReactiveState = Object.freeze({ data: undefined, error: undefined, - status: 'loading', + status: 'idle', }); /** @@ -77,19 +61,35 @@ const LOADING_STATE: ReactiveState = Object.freeze({ * systems to subscribe to changes. Compatible with `useSyncExternalStore`, Svelte stores, Solid's * `from()`, and other reactive primitives that expect a `{ subscribe, getUnifiedState }` contract. * + * The store starts in `status: 'idle'`. Call {@link ReactiveStreamStore.connect | `connect()`} + * to open the underlying stream; the store transitions through `loading` → `loaded` (or `error`). + * Subsequent `connect()` calls also pass through `loading` while preserving the last known + * `data` and `error` (stale-while-revalidate). + * * @example * ```ts * // React — the unified state snapshot has stable identity per update, making it suitable as * // the second argument to `useSyncExternalStore`. * const state = useSyncExternalStore(store.subscribe, store.getUnifiedState); - * if (state.status === 'error') return ; - * if (state.status === 'loading') return ; + * useEffect(() => { + * store.connect(); + * return () => store.reset(); + * }, [store]); + * if (state.status === 'error') return ; + * if (state.status === 'loading' || state.status === 'idle') return ; * return ; * ``` * * @see {@link createReactiveStoreFromDataPublisherFactory} */ export type ReactiveStreamStore = { + /** + * Open the underlying stream. Aborts any currently active connection, invokes the configured + * factory, and transitions the store to `loading` (preserving the last known `data` and + * `error` for stale-while-revalidate) before settling into `loaded` (on data) or `error` + * (on failure). + */ + connect(): void; /** * Returns the error published to the error channel, or `undefined` if no error has occurred. * @@ -103,7 +103,7 @@ export type ReactiveStreamStore = { * notification has arrived yet. On error, continues to return the last known value. * * @deprecated Use {@link ReactiveStreamStore.getUnifiedState | `getUnifiedState()`} instead. This - * getter returns only the value field and does not surface lifecycle status (e.g. `retrying`). + * getter returns only the value field and does not surface lifecycle status (e.g. `loading`). */ getState(): T | undefined; /** @@ -115,7 +115,18 @@ export type ReactiveStreamStore = { */ getUnifiedState(): ReactiveState; /** - * Re-opens the stream after an error. No-op when the store is not in the `error` state. + * Aborts any currently active connection and resets the store to `{ status: 'idle' }`. Both + * `data` and `error` are cleared. Use this to tear down the connection without permanently + * killing the store — a follow-up {@link ReactiveStreamStore.connect | `connect()`} will open + * a fresh stream. + */ + reset(): void; + /** + * Re-opens the stream after an error. No-op when the store is not in `status: 'error'`. + * + * @deprecated Use {@link ReactiveStreamStore.connect | `connect()`} instead. `connect()` + * always (re)connects, regardless of current status — wrap with a status guard at the call + * site if you need the error-only behaviour. */ retry(): void; /** @@ -123,6 +134,26 @@ export type ReactiveStreamStore = { * Returns an unsubscribe function. Safe to call multiple times. */ subscribe(callback: () => void): () => void; + /** + * Returns a thin wrapper exposing `connect()` that composes `signal` with the store's internal + * per-connection controller via `AbortSignal.any` — aborting either tears down the active + * connection. Aborting the caller-provided signal surfaces the abort reason on state as + * `{ status: 'error' }`; the internal controller path (supersession by a newer `connect()` or + * `reset()`) is silent by design so the newer call owns state. Use this to attach a + * caller-provided cancellation source (per-connection timeout, shared kill switch, + * parent-context signal) without touching the bare `connect()` API. + * + * - Per-connection timeout: `store.withSignal(AbortSignal.timeout(30_000)).connect()` — fresh + * clock per call. + * - Permanent kill switch: hold one `AbortController`, bind the wrapper once + * (`const killable = store.withSignal(killCtrl.signal)`), and use `killable.connect()` + * everywhere; aborting the controller cancels the active connection and short-circuits + * future calls through the bound wrapper. + * + * The wrapper exposes only `connect()` — `getUnifiedState` / `subscribe` / `reset` remain + * store-level concerns on the parent. + */ + withSignal(signal: AbortSignal): { readonly connect: () => void }; }; /** @@ -132,17 +163,24 @@ export type ReactiveStreamStore = { export type ReactiveStore = ReactiveStreamStore; /** - * Duck-type for objects that build a {@link ReactiveStreamStore} on demand via a - * `reactiveStore({ abortSignal })` method. Satisfied by `PendingRpcSubscriptionsRequest`. - * Reactive-framework bindings (e.g. React's `useSubscription`) consume this duck-type so they - * don't have to name a concrete producer type. + * Duck-type for objects that build a {@link ReactiveStreamStore} on demand via a `reactiveStore()` + * method. Satisfied by `PendingRpcSubscriptionsRequest`. Reactive-framework bindings (e.g. + * React's `useSubscription`) consume this duck-type so they don't have to name a concrete producer + * type. + * + * The returned store is in `status: 'idle'` — the caller is responsible for invoking + * {@link ReactiveStreamStore.connect | `connect()`} to open the underlying stream. Attach a + * caller-provided cancellation source via {@link ReactiveStreamStore.withSignal | `withSignal()`} + * — `store.withSignal(signal).connect()`. * * @typeParam T - The value type emitted by the resulting stream store. * * @example * ```ts - * function bind(source: ReactiveStreamSource, abortSignal: AbortSignal) { - * return source.reactiveStore({ abortSignal }); + * function bindWithTimeout(source: ReactiveStreamSource) { + * const store = source.reactiveStore(); + * store.withSignal(AbortSignal.timeout(30_000)).connect(); + * return store; * } * ``` * @@ -150,119 +188,38 @@ export type ReactiveStore = ReactiveStreamStore; * @see {@link ReactiveActionSource} */ export type ReactiveStreamSource = { - reactiveStore(options: { abortSignal: AbortSignal }): ReactiveStreamStore; + reactiveStore(): ReactiveStreamStore; }; -/** - * Returns a {@link ReactiveStreamStore} given a data publisher. - * - * The store will update its state with each message published to `dataChannelName` and notify all - * subscribers. When a message is published to `errorChannelName`, subscribers are notified so they - * can react to the error condition, but the last-known state is preserved. Triggering the abort - * signal disconnects the store from the data publisher. - * - * Things to note: - * - * - `getUnifiedState()` starts in `status: 'loading'` until the first notification arrives. - * - On error, `getUnifiedState().data` continues to return the last known value and `error` holds - * the failure. Only the first error is captured. - * - The function returned by `subscribe` is idempotent — calling it multiple times is safe. - * - Because a `DataPublisher` instance cannot be restarted, {@link ReactiveStreamStore.retry | `retry()`} - * on the returned store throws a - * {@link SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED | `SolanaError`}. - * - * @param config - * - * @deprecated Use {@link createReactiveStoreFromDataPublisherFactory} instead. That variant accepts - * a factory function for the underlying {@link DataPublisher} and can therefore support - * {@link ReactiveStreamStore.retry | `retry()`}. - */ -export function createReactiveStoreFromDataPublisher({ - abortSignal, - dataChannelName, - dataPublisher, - errorChannelName, -}: Config): ReactiveStreamStore { - let currentState: ReactiveState = LOADING_STATE; - const subscribers = new Set<() => void>(); - - const abortController = new AbortController(); - abortSignal.addEventListener('abort', () => abortController.abort(abortSignal.reason)); - - function notify() { - subscribers.forEach(cb => cb()); - } - - dataPublisher.on( - dataChannelName, - data => { - currentState = { data: data as TData, error: undefined, status: 'loaded' }; - notify(); - }, - { signal: abortController.signal }, - ); - dataPublisher.on( - errorChannelName, - err => { - if (currentState.status === 'error') return; - currentState = { data: currentState.data, error: err, status: 'error' }; - abortController.abort(err); - notify(); - }, - { signal: abortController.signal }, - ); - - return { - getError(): unknown { - return currentState.error; - }, - getState(): TData | undefined { - return currentState.data; - }, - getUnifiedState(): ReactiveState { - return currentState; - }, - retry(): void { - throw new SolanaError(SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED); - }, - subscribe(callback: () => void): () => void { - subscribers.add(callback); - return () => { - subscribers.delete(callback); - }; - }, - }; -} - /** * Returns a {@link ReactiveStreamStore} that wires itself to a fresh {@link DataPublisher} on - * construction and on every {@link ReactiveStreamStore.retry | `retry()`}. + * every {@link ReactiveStreamStore.connect | `connect()`}. * - * Unlike {@link createReactiveStoreFromDataPublisher}, this variant accepts a `createDataPublisher` - * factory rather than a ready-made publisher. That lets the store tear down a broken stream and - * open a new one without losing subscribers or the last known value. + * The store accepts a `createDataPublisher` factory rather than a ready-made publisher — that + * lets the store tear down a broken stream and open a new one without losing subscribers or the + * last known value. The factory receives the per-connection signal so the underlying transport + * can stop on per-connection abort, not just the stream-store's listeners. * * Things to note: * - * - `getUnifiedState()` starts in `status: 'loading'` until the first notification arrives. - * - On error, the store transitions to `status: 'error'` preserving the last known value. Only the - * first error per connection window is captured — a subsequent `retry()` resets that window. - * - `retry()` is a no-op unless the store is currently in `status: 'error'`. When it fires, the - * store transitions to `status: 'retrying'` (preserving stale data), invokes - * `createDataPublisher()`, and wires up a fresh connection. If the factory rejects, the store - * transitions to `status: 'error'` with the rejection reason. - * - Triggering the caller's `abortSignal` disconnects the store permanently; subsequent `retry()` - * calls are no-ops. + * - The returned store starts in `status: 'idle'`. Call `connect()` to open the first stream. + * - `createDataPublisher` is invoked on every `connect()`. The store transitions through + * `loading`, preserving the last known `data` and `error` (stale-while-revalidate). + * - If `createDataPublisher` rejects, the store transitions to `status: 'error'` with the + * rejection as the error. Call `connect()` to try again. + * - `reset()` aborts the current connection and returns the store to `idle`, clearing `data` + * and `error`. A follow-up `connect()` opens a fresh stream. + * - Attach a caller-provided cancellation source via + * {@link ReactiveStreamStore.withSignal | `withSignal()`} — `store.withSignal(signal).connect()` + * composes the signal with the per-connection controller. Aborting the caller's signal + * transitions the store to `error` with that abort reason. * * @param config * * @example * ```ts * const store = createReactiveStoreFromDataPublisherFactory({ - * abortSignal, - * async createDataPublisher() { - * return getDataPublisherFromEventEmitter(new WebSocket(url)); - * }, + * createDataPublisher: signal => getDataPublisherFromEventEmitter(new WebSocket(url, { signal })), * dataChannelName: 'message', * errorChannelName: 'error', * }); @@ -271,68 +228,102 @@ export function createReactiveStoreFromDataPublisher({ * if (snapshot.status === 'error') console.error('Connection failed:', snapshot.error); * else if (snapshot.status === 'loaded') console.log('Latest:', snapshot.data); * }); - * // Call `store.retry()` to recover after an error — e.g. from a user-triggered "Retry" button. + * // Fresh 30-second clock per connection attempt: + * store.withSignal(AbortSignal.timeout(30_000)).connect(); * ``` */ export function createReactiveStoreFromDataPublisherFactory({ - abortSignal, createDataPublisher, dataChannelName, errorChannelName, }: FactoryConfig): ReactiveStreamStore { - let currentState: ReactiveState = LOADING_STATE; + let currentState: ReactiveState = IDLE_STATE; + let currentInnerController: AbortController | undefined; const subscribers = new Set<() => void>(); - const outerController = new AbortController(); - abortSignal.addEventListener('abort', () => outerController.abort(abortSignal.reason)); - function notify() { subscribers.forEach(cb => cb()); } - function connect() { - if (outerController.signal.aborted) return; - // Inner signal is passed to data publisher + function setState(next: ReactiveState) { + if ( + currentState.status === next.status && + currentState.data === next.data && + currentState.error === next.error + ) { + return; + } + currentState = next; + notify(); + } + + function performConnect(callerSignal: AbortSignal | undefined) { + // Abort any currently active connection before starting a fresh one. + currentInnerController?.abort(); + // If the caller's signal is already aborted, surface as error and bail. + if (callerSignal?.aborted) { + setState({ data: currentState.data, error: callerSignal.reason, status: 'error' }); + return; + } + // Transition to `loading`, preserving the last known `data` and `error` for SWR. If + // already `loading` with the same data/error, `setState` no-ops — no spurious notify. + setState({ data: currentState.data, error: currentState.error, status: 'loading' }); + // Inner signal is passed to the data publisher (composed with caller signal if any). const innerController = new AbortController(); - // Forward an abort from the outer signal to the inner one, so that when the caller aborts, we disconnect - // Scope this forwarder to the inner signal so it's removed on reconnection - // and we don't accumulate listeners on the outer signal across retries. - const forwardAbort = () => innerController.abort(outerController.signal.reason); - outerController.signal.addEventListener('abort', forwardAbort, { signal: innerController.signal }); - createDataPublisher().then( + currentInnerController = innerController; + const signal = callerSignal ? AbortSignal.any([innerController.signal, callerSignal]) : innerController.signal; + // Caller's signal aborting (not just supersede via the inner controller) transitions the + // store to error with the caller's abort reason. Scoped to the inner signal so the + // listener is removed automatically on reconnect / reset. + if (callerSignal) { + callerSignal.addEventListener( + 'abort', + () => { + if (innerController.signal.aborted) return; + setState({ data: currentState.data, error: callerSignal.reason, status: 'error' }); + innerController.abort(callerSignal.reason); + }, + { signal: innerController.signal }, + ); + } + createDataPublisher(signal).then( publisher => { - if (innerController.signal.aborted) return; + if (signal.aborted) return; publisher.on( dataChannelName, data => { - currentState = { data: data as TData, error: undefined, status: 'loaded' }; - notify(); + setState({ data: data as TData, error: undefined, status: 'loaded' }); }, - { signal: innerController.signal }, + { signal }, ); publisher.on( errorChannelName, err => { if (currentState.status === 'error') return; - currentState = { data: currentState.data, error: err, status: 'error' }; + setState({ data: currentState.data, error: err, status: 'error' }); innerController.abort(err); - notify(); }, - { signal: innerController.signal }, + { signal }, ); }, err => { - if (innerController.signal.aborted) return; - currentState = { data: currentState.data, error: err, status: 'error' }; + if (signal.aborted) return; + setState({ data: currentState.data, error: err, status: 'error' }); innerController.abort(err); - notify(); }, ); } - connect(); + function performReset() { + currentInnerController?.abort(); + currentInnerController = undefined; + setState(IDLE_STATE); + } return { + connect(): void { + performConnect(undefined); + }, getError(): unknown { return currentState.error; }, @@ -342,12 +333,10 @@ export function createReactiveStoreFromDataPublisherFactory({ getUnifiedState(): ReactiveState { return currentState; }, + reset: performReset, retry(): void { - if (outerController.signal.aborted) return; if (currentState.status !== 'error') return; - currentState = { data: currentState.data, error: undefined, status: 'retrying' }; - notify(); - connect(); + performConnect(undefined); }, subscribe(callback: () => void): () => void { subscribers.add(callback); @@ -355,5 +344,12 @@ export function createReactiveStoreFromDataPublisherFactory({ subscribers.delete(callback); }; }, + withSignal(signal: AbortSignal) { + return { + connect(): void { + performConnect(signal); + }, + }; + }, }; } diff --git a/packages/test-config/browser-environment.ts b/packages/test-config/browser-environment.ts index d0835c115..fb7d1e7c3 100644 --- a/packages/test-config/browser-environment.ts +++ b/packages/test-config/browser-environment.ts @@ -10,5 +10,35 @@ export default class BrowserEnvironment extends TestEnvironment { */ this.global.ArrayBuffer = globalThis.ArrayBuffer; this.global.Uint8Array = globalThis.Uint8Array; + /** + * Polyfill `AbortSignal.any` on jsdom's `AbortSignal` class if missing. jsdom 22 doesn't + * implement it (added natively in jsdom 24, in Node 20.3, and shipped in all current + * browsers). Cross-realm replacement of the whole class would break jsdom's internal + * brand checks elsewhere, so this patches just the static method. + */ + const JsdomAbortSignal = this.global.AbortSignal as typeof globalThis.AbortSignal & { + any?: typeof globalThis.AbortSignal.any; + }; + const JsdomAbortController = this.global.AbortController as typeof globalThis.AbortController; + if (typeof JsdomAbortSignal.any !== 'function') { + JsdomAbortSignal.any = function any(signals: readonly AbortSignal[]): AbortSignal { + const controller = new JsdomAbortController(); + const alreadyAborted = signals.find(s => s.aborted); + if (alreadyAborted) { + controller.abort(alreadyAborted.reason); + return controller.signal; + } + for (const inputSignal of signals) { + inputSignal.addEventListener( + 'abort', + () => { + if (!controller.signal.aborted) controller.abort(inputSignal.reason); + }, + { once: true, signal: controller.signal }, + ); + } + return controller.signal; + }; + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3aca1ebc..448f61159 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -944,27 +944,9 @@ importers: packages/react: dependencies: - '@solana/addresses': - specifier: workspace:* - version: link:../addresses - '@solana/errors': - specifier: workspace:* - version: link:../errors - '@solana/keys': - specifier: workspace:* - version: link:../keys '@solana/promises': specifier: workspace:* version: link:../promises - '@solana/signers': - specifier: workspace:* - version: link:../signers - '@solana/transaction-messages': - specifier: workspace:* - version: link:../transaction-messages - '@solana/transactions': - specifier: workspace:* - version: link:../transactions '@solana/wallet-standard-features': specifier: ^1.3.0 version: 1.3.0 @@ -984,21 +966,21 @@ importers: specifier: ^1.0.1 version: 1.0.1 devDependencies: - '@solana/codecs-core': - specifier: workspace:* - version: link:../codecs-core '@solana/eslint-config': specifier: workspace:* version: link:../eslint-config - '@solana/rpc-types': + '@solana/kit': specifier: workspace:* - version: link:../rpc-types + version: link:../kit '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/react': specifier: ^19.2.15 version: 19.2.15 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.15) '@types/react-test-renderer': specifier: ^19.1.0 version: 19.1.0 @@ -1014,6 +996,9 @@ importers: react-test-renderer: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + swr: + specifier: ^2.4.1 + version: 2.4.1(react@19.2.6) packages/rpc: dependencies: