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); +}