Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/humble-coins-speak.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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.
23 changes: 23 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<ClientWithRpcSubscriptions<AccountNotificationsApi>>();
const { data, error } = useSubscriptionSWR(
address ? ['account', address] : null,
address ? client.rpcSubscriptions.accountNotifications(address) : null,
);
if (error) return <p>Failed to connect.</p>;
if (!data) return <p>Connecting…</p>;
return (
<p>
{data.value.lamports} lamports at slot {data.context.slot}
</p>
);
}
```

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`.
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
* @packageDocumentation
*/
export * from './swr/useRequestSWR';
export * from './swr/useSubscriptionSWR';
289 changes: 289 additions & 0 deletions packages/react/src/swr/__tests__/useSubscriptionSWR-test.browser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SWRConfig
value={{
errorRetryCount: 0,
provider: () => new Map(),
revalidateOnFocus: false,
revalidateOnReconnect: false,
}}
>
{children}
</SWRConfig>
);
}

function makeFakeSubscription<T>(): {
activeConnections: () => number;
dataListenerCount: () => number;
publish: (notification: T) => Promise<void>;
publishError: (err: unknown) => Promise<void>;
publishersCreated: () => number;
source: ReactiveStreamSource<T>;
} {
type Listener = (payload: unknown) => void;
let dataListeners: Listener[] = [];
let errorListeners: Listener[] = [];
let activeCount = 0;
let createdCount = 0;
let publisherReady = Promise.withResolvers<void>();
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<T>({
createDataPublisher() {
createdCount++;
activeCount++;
dataListeners = [];
errorListeners = [];
publisherReady = Promise.withResolvers<void>();
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<SolanaRpcResponse<{ lamports: bigint }>>();
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 <p>{data ? 'has-data' : 'no-data'}</p>;
}
const html = renderToString(
<SWRConfig value={{ provider: () => new Map() }}>
<Component />
</SWRConfig>,
);
expect(html).toBe('<p>no-data</p>');
expect(reactiveStore).not.toHaveBeenCalled();
});
});
});
Loading
Loading