From b1c4037523a7d2a6764096bcaf59f61353509d82 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 3 Jun 2026 11:16:43 +0000 Subject: [PATCH] Add useSubscriptionSWR adapter Add an SWR-backed subscription hook for ReactiveStreamSource values on the @solana/react/swr subpath. The hook shares subscriptions by SWR key, returns SWR's native data/error shape, supports nullable key or source gating, and threads Kit's getAbortSignal option into each connection. Document the new hook, export it from the SWR entrypoint, and cover notification passthrough, error handling, disabled states, key/source lifecycle behavior, shared subscriptions, StrictMode rendering, SSR safety, and public typing. --- .changeset/humble-coins-speak.md | 13 + packages/react/README.md | 23 ++ packages/react/src/swr.ts | 1 + .../useSubscriptionSWR-test.browser.tsx | 289 ++++++++++++++++++ .../useSubscriptionSWR-typetest.ts | 78 +++++ packages/react/src/swr/useSubscriptionSWR.ts | 83 +++++ 6 files changed, 487 insertions(+) create mode 100644 .changeset/humble-coins-speak.md create mode 100644 packages/react/src/swr/__tests__/useSubscriptionSWR-test.browser.tsx create mode 100644 packages/react/src/swr/__typetests__/useSubscriptionSWR-typetest.ts create mode 100644 packages/react/src/swr/useSubscriptionSWR.ts diff --git a/.changeset/humble-coins-speak.md b/.changeset/humble-coins-speak.md new file mode 100644 index 000000000..c03afdeb8 --- /dev/null +++ b/.changeset/humble-coins-speak.md @@ -0,0 +1,13 @@ +--- +'@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)); +``` + +`data` is the notification exactly as the source emits it. Pass `null` for either `key` or `source` to disable. Options accept SWR's config plus `getAbortSignal` for an abort signal. diff --git a/packages/react/README.md b/packages/react/README.md index b1287653c..219b87403 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -354,6 +354,29 @@ function Profile({ userId }: { userId: string }) { When `getAbortSignal` isn't configured the signal is a fresh never-aborting `AbortSignal` (so the function's signature is satisfied) — it does **not** fire on unmount or when SWR supersedes the request. SWR's model is to discard the stale result rather than cancel the network call. +### `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 — `data` is the notification exactly as the source emits it. Pass `null` for either `key` or `source` to disable. Options accept SWR's config. SWR subscriptions surface only `{ data, error }`, so there is no `reconnect` function like `useSubscription` has — reach for `useSubscription` when you need manual reconnection. For the same reason `getAbortSignal` is not available. + +```tsx +function AccountBalance({ address }: { address: Address }) { + const client = useClient>(); + const { data, error } = useSubscriptionSWR( + address ? ['account', address] : null, + address ? client.rpcSubscriptions.accountNotifications(address) : null, + ); + if (error) return

Failed to connect.

; + if (!data) return

Connecting…

; + return ( +

+ {data.value.lamports} lamports at slot {data.context.slot} +

+ ); +} +``` + +If the `source` changes (new address, new notification type) but the SWR `key` is stable, the existing connection stays bound to the original source — SWR caches on `key`, and `subscribe` reads the source from a ref. Bump the `key` to swap sources. + ### Why no `useActionSWR`? It would just be a wrapper around SWR's built-in [`useSWRMutation`](https://swr.vercel.app/docs/mutation#useswrmutation) with no additional functionality. Either use `useSWRMutation` or, if you don't need the SWR integration, use `useAction`. diff --git a/packages/react/src/swr.ts b/packages/react/src/swr.ts index d0f395131..812fe1a7d 100644 --- a/packages/react/src/swr.ts +++ b/packages/react/src/swr.ts @@ -10,3 +10,4 @@ * @packageDocumentation */ export * from './swr/useRequestSWR'; +export * from './swr/useSubscriptionSWR'; 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..d22112887 --- /dev/null +++ b/packages/react/src/swr/__tests__/useSubscriptionSWR-test.browser.tsx @@ -0,0 +1,289 @@ +import { + createReactiveStoreFromDataPublisherFactory, + DataPublisher, + ReactiveStreamSource, + SolanaRpcResponse, +} 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 { useSubscriptionSWR } from '../useSubscriptionSWR'; + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + new Map(), + revalidateOnFocus: false, + revalidateOnReconnect: false, + }} + > + {children} + + ); +} + +function makeFakeSubscription(): { + activeConnections: () => number; + dataListenerCount: () => number; + publish: (notification: T) => Promise; + publishError: (err: unknown) => Promise; + publishersCreated: () => number; + source: ReactiveStreamSource; +} { + type Listener = (payload: unknown) => void; + let dataListeners: Listener[] = []; + let errorListeners: Listener[] = []; + let activeCount = 0; + let createdCount = 0; + let publisherReady = Promise.withResolvers(); + return { + activeConnections: () => activeCount, + dataListenerCount: () => dataListeners.length, + async publish(notification) { + 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++; + activeCount++; + dataListeners = []; + errorListeners = []; + publisherReady = Promise.withResolvers(); + let onCallCount = 0; + let cleanedUp = false; + const cleanup = () => { + if (!cleanedUp) { + cleanedUp = true; + activeCount--; + } + }; + 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); + cleanup(); + }, + { 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', + }); + }, + }, + }; +} + +async function waitForSubscriptionToSettle() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForActiveSubscription(sub: { activeConnections: () => number; dataListenerCount: () => number }) { + await waitFor(() => { + expect(sub.activeConnections()).toBe(1); + expect(sub.dataListenerCount()).toBeGreaterThan(0); + }); +} + +describe('useSubscriptionSWR', () => { + it('surfaces SolanaRpcResponse envelopes as-is so callers read .value and .context.slot', async () => { + const sub = makeFakeSubscription>(); + const { result } = renderHook(() => useSubscriptionSWR(['account'], sub.source), { wrapper }); + await waitForActiveSubscription(sub); + 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 }); + await waitForActiveSubscription(sub); + 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 }); + await waitForActiveSubscription(sub); + 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('surfaces a later error while retaining the last notification', async () => { + // SWR's `useSWRSubscription` tracks `data` and `error` independently: an error published + // after a notification surfaces `error` without clearing the retained `data`. + // + // The `expect(result.current.error)` read before the failure is load-bearing: SWR only + // re-renders for fields read during render (its `stateDependencies` tracking), so the `error` + // field must be observed before the failure for the update to propagate. A component that + // reads `error` (as the hook's JSDoc example does) gets this for free. + const sub = makeFakeSubscription<{ value: number }>(); + const { result } = renderHook(() => useSubscriptionSWR(['err-after-data'], sub.source), { wrapper }); + await waitForActiveSubscription(sub); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + + await act(async () => sub.publish({ value: 1 })); + await waitFor(() => expect(result.current.data).toStrictEqual({ value: 1 })); + + const boom = new Error('boom after load'); + await act(async () => sub.publishError(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + // The prior notification is retained alongside the surfaced error. + expect(result.current.data).toStrictEqual({ value: 1 }); + }); + + 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 waitForSubscriptionToSettle(); + 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 waitForSubscriptionToSettle(); + expect(result.current.data).toBeUndefined(); + }); + + it('opens the subscription 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 }) => useSubscriptionSWR(['source-set'], source), { + initialProps, + wrapper, + }); + await waitForSubscriptionToSettle(); + expect(sub.publishersCreated()).toBe(0); + expect(result.current.data).toBeUndefined(); + + rerender({ source: sub.source }); + await waitForActiveSubscription(sub); + await act(async () => sub.publish({ value: 1 })); + await waitFor(() => expect(result.current.data).toStrictEqual({ value: 1 })); + expect(sub.activeConnections()).toBe(1); + }); + + it('opens the subscription when the key transitions from null to non-null', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + const initialProps: { key: string | null } = { key: null }; + const { rerender } = renderHook( + ({ key }) => useSubscriptionSWR(key == null ? null : ['key-set', key], sub.source), + { + initialProps, + wrapper, + }, + ); + await waitForSubscriptionToSettle(); + expect(sub.publishersCreated()).toBe(0); + + rerender({ key: 'ready' }); + await waitForActiveSubscription(sub); + expect(sub.activeConnections()).toBe(1); + }); + + it('tears down the subscription when the source transitions to null', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + let source: ReactiveStreamSource<{ value: number }> | null = sub.source; + const { rerender } = renderHook(() => useSubscriptionSWR(['source-null'], source), { wrapper }); + await waitForActiveSubscription(sub); + + source = null; + rerender(); + await waitFor(() => expect(sub.activeConnections()).toBe(0)); + await act(async () => sub.publish({ value: 2 })); + }); + + it('tears down the old subscription and opens a new one when the key changes', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + let key = 'a'; + const { rerender } = renderHook(() => useSubscriptionSWR([key], sub.source), { wrapper }); + await waitForActiveSubscription(sub); + const createdBeforeKeyChange = sub.publishersCreated(); + + key = 'b'; + rerender(); + await waitFor(() => expect(sub.publishersCreated()).toBeGreaterThan(createdBeforeKeyChange)); + await waitForActiveSubscription(sub); + }); + + it('shares one active underlying subscription across components with the same key', async () => { + const sub = makeFakeSubscription<{ value: number }>(); + renderHook( + () => [useSubscriptionSWR(['shared'], sub.source), useSubscriptionSWR(['shared'], sub.source)] as const, + { wrapper }, + ); + await waitFor(() => expect(sub.activeConnections()).toBe(1)); + }); + + it('keeps using the current subscription when the source changes but the key is stable', async () => { + const subA = makeFakeSubscription<{ value: number }>(); + const subB = makeFakeSubscription<{ value: number }>(); + let source: ReactiveStreamSource<{ value: number }> = subA.source; + const { rerender } = renderHook(() => useSubscriptionSWR(['stable-key'], source), { wrapper }); + await waitForActiveSubscription(subA); + + source = subB.source; + rerender(); + await waitForSubscriptionToSettle(); + expect(subB.publishersCreated()).toBe(0); + expect(subA.activeConnections()).toBe(1); + }); + + 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__/useSubscriptionSWR-typetest.ts b/packages/react/src/swr/__typetests__/useSubscriptionSWR-typetest.ts new file mode 100644 index 000000000..d3c2feaec --- /dev/null +++ b/packages/react/src/swr/__typetests__/useSubscriptionSWR-typetest.ts @@ -0,0 +1,78 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { ReactiveStreamSource, SolanaRpcResponse } from '@solana/kit'; +import type { SWRSubscriptionResponse } from 'swr/subscription'; + +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) satisfies SWRSubscriptionResponse<{ slot: bigint }, unknown>; + } + + // Null source disables. + { + useSubscriptionSWR<{ slot: bigint }>(['slot'], null) satisfies SWRSubscriptionResponse< + { slot: bigint }, + unknown + >; + } + + // Default error type is unknown. + { + const { error } = useSubscriptionSWR(['slot'], slotSource); + error satisfies unknown; + // @ts-expect-error - errors default to unknown, not Error. + error satisfies Error | undefined; + } + + // TError is overridable via the generic. + { + useSubscriptionSWR<{ slot: bigint }, string>(['slot'], slotSource).error satisfies string | undefined; + } + + // Options are forwarded to SWR's configuration and typed against the subscription data. + { + useSubscriptionSWR(['slot'], slotSource, { + fallbackData: { slot: 1n }, + // @ts-expect-error - SWR doesn't accept arbitrary keys. + notARealOption: true, + onSuccess(data) { + data.slot satisfies bigint; + }, + revalidateOnFocus: false, + }); + useSubscriptionSWR(['slot'], slotSource, { + // @ts-expect-error - fallback data must match the subscription notification type. + fallbackData: { nope: 1n }, + }); + } + + // This hook does not accept a getAbortSignal option (lifetime is controlled via the key). + { + useSubscriptionSWR(['slot'], slotSource, { + // @ts-expect-error - getAbortSignal is not part of the SWR adapter's options. + getAbortSignal: () => AbortSignal.timeout(30_000), + }); + } +} diff --git a/packages/react/src/swr/useSubscriptionSWR.ts b/packages/react/src/swr/useSubscriptionSWR.ts new file mode 100644 index 000000000..f50545700 --- /dev/null +++ b/packages/react/src/swr/useSubscriptionSWR.ts @@ -0,0 +1,83 @@ +import type { ReactiveStreamSource } from '@solana/kit'; +import { useCallback } from 'react'; +import type { Key as SWRKey, SWRConfiguration } from 'swr'; +import useSWRSubscription, { type SWRSubscriptionOptions, type SWRSubscriptionResponse } from 'swr/subscription'; + +import { useLatest } from '../useLatest'; + +/** + * 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. For RPC subscriptions that emit `SolanaRpcResponse` notifications, read the inner + * value at `data.value` and the slot at `data.context.slot`. For raw notifications, `data` + * is the raw shape. + * + * Pass `null` for `key` or `source` to disable. + * + * SWR subscriptions surface only `{ data, error }`, so this hook has no `reconnect` and no + * `getAbortSignal` option. To stop or restart a subscription, toggle the `key` to/from `null`. + * + * If the `source` changes but the SWR `key` is stable, the existing connection stays bound to + * the original source — SWR caches on `key`, and `subscribe` reads the source from a ref. + * Bump the `key` to swap sources. + * + * @typeParam T - The notification type emitted by the source. + * @typeParam TError - The error type SWR will surface on failure. + * + * @example + * ```tsx + * function AccountBalance({ address }: { address: Address }) { + * const client = useClient>(); + * const { data, error } = useSubscriptionSWR( + * address ? ['account', address] : null, + * address ? client.rpcSubscriptions.accountNotifications(address) : null, + * ); + * if (error) return

Failed to connect.

; + * if (!data) return

Connecting…

; + * return

{data.value.lamports} lamports at slot {data.context.slot}

; + * } + * ``` + */ +export function useSubscriptionSWR( + key: SWRKey, + source: ReactiveStreamSource | null, + options?: SWRConfiguration, +): SWRSubscriptionResponse { + const swrConfig = options ?? {}; + + // Ref-sync the source so an inline value passed each render doesn't change the `subscribe` + // callback's identity. `subscribe` reads the latest source from the ref when SWR invokes it. + const sourceRef = useLatest(source); + + const subscribe = useCallback( + (_key: SWRKey, { next }: SWRSubscriptionOptions) => { + const current = sourceRef.current; + // defensive - we set key to null when source is null, so `subscribe` shouldn't be called + // with a null source + if (!current) return () => { }; + // Note: while SWR doesn't sync an initial state like `useSyncExternalStore` + // the `.reactiveStore()` contract returns an idle store, which stays idle + // until we `connect`. So we don't need to forward an initial state here. + 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 as TError); + }); + store.connect(); + return () => { + unsubscribe(); + store.reset(); + }; + // `sourceRef` is a stable ref from `useLatest`, so listing it leaves `subscribe` + // referentially stable across renders — SWR keys off `key`, not the subscribe + // identity, but a stable callback avoids needless re-subscription churn. + }, + [sourceRef], + ); + // Force the key to `null` when there's no source — either-null disables the subscription. + return useSWRSubscription(source == null ? null : key, subscribe, swrConfig); +}