diff --git a/.changeset/neat-coats-boil.md b/.changeset/neat-coats-boil.md new file mode 100644 index 000000000..2cf1d6cb2 --- /dev/null +++ b/.changeset/neat-coats-boil.md @@ -0,0 +1,14 @@ +--- +'@solana/react': minor +--- + +Add `useTrackedDataSWR(key, spec, options?)` to the `@solana/react/swr` subpath — the SWR-backed counterpart to `useTrackedData`. Takes the same `TrackedDataSpec` and routes the unified, slot-deduped stream through SWR's `useSWRSubscription`. + +```tsx +import { useTrackedDataSWR } from '@solana/react/swr'; + +const { data } = useTrackedDataSWR(['balance', address], spec); +// data is `SolanaRpcResponse | undefined` +``` + +`data` is shape `SolanaRpcResponse`, because this hook requires the slot for de-duping. Mirrors core `useTrackedData`. Pass `null` for either `key` or `spec` to disable. Options accept SWR's config plus `getAbortSignal` for a custom abort signal. diff --git a/packages/react/README.md b/packages/react/README.md index 219b87403..4675bc09a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -377,6 +377,32 @@ function AccountBalance({ address }: { address: Address }) { 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. +### `useTrackedDataSWR(key, spec, options?)` + +SWR-backed counterpart to `useTrackedData`. Takes the same `TrackedDataSpec` (RPC fetch + subscription pair + value mappers) and routes the unified, slot-deduped stream through SWR. Returns SWR's native `{ data, error }` shape — `data` is the `SolanaRpcResponse` envelope emitted by the underlying kit primitive, so callers can read `data.value` (the unified item produced by the mappers) and `data.context.slot` (the slot the store dedup'd on) directly. Pass `null` for `key` or `spec` to disable. SWR subscriptions surface only `{ data, error }`, so there is no `refresh` function like `useTrackedData` has — reach for `useTrackedData` when you need manual refresh. For the same reason `getAbortSignal` is not available. + +```tsx +function AccountBalance({ address }: { address: Address }) { + const client = useClient & ClientWithRpcSubscriptions>(); + const spec = useMemo( + () => + address + ? { + initialValueSource: client.rpc.getBalance(address), + initialValueMapper: (lamports: bigint) => lamports, + streamSource: client.rpcSubscriptions.accountNotifications(address), + streamValueMapper: ({ lamports }: { lamports: bigint }) => lamports, + } + : null, + [client, address], + ); + const { data } = useTrackedDataSWR(address ? ['balance', address] : null, spec); + return

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

; +} +``` + +If the `spec` changes (new mappers, new source) but the SWR `key` is stable, the existing connection stays bound to the original spec — SWR caches on `key`, and `subscribe` reads the spec from a ref. Bump the `key` to swap specs. + ### 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 812fe1a7d..0c2947837 100644 --- a/packages/react/src/swr.ts +++ b/packages/react/src/swr.ts @@ -11,3 +11,4 @@ */ export * from './swr/useRequestSWR'; export * from './swr/useSubscriptionSWR'; +export * from './swr/useTrackedDataSWR'; diff --git a/packages/react/src/swr/__tests__/useTrackedDataSWR-test.browser.tsx b/packages/react/src/swr/__tests__/useTrackedDataSWR-test.browser.tsx new file mode 100644 index 000000000..0f0ca3077 --- /dev/null +++ b/packages/react/src/swr/__tests__/useTrackedDataSWR-test.browser.tsx @@ -0,0 +1,418 @@ +import { + createReactiveActionStore, + createReactiveStoreFromDataPublisherFactory, + ReactiveActionSource, + 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 { TrackedDataSpec } from '../../useTrackedData'; +import { useTrackedDataSWR } from '../useTrackedDataSWR'; + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + new Map(), + revalidateOnFocus: false, + revalidateOnReconnect: false, + }} + > + {children} + + ); +} + +// A wrapper whose SWR cache provider is a single shared `Map`, so cache entries survive an +// unmount and are visible to a later remount under the same key. +function makePersistentWrapper() { + const cache = new Map(); + return function PersistentWrapper({ children }: { children: React.ReactNode }) { + return ( + cache, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }} + > + {children} + + ); + }; +} + +type TestValue = { count: number }; + +function rpcResponse(slot: number, value: TestValue): SolanaRpcResponse { + return { context: { slot: BigInt(slot) }, value }; +} + +// Backs `initialValueSource` with a real `ReactiveActionStore`. `resolve`/`reject` latch their +// outcome and apply it to every dispatch — past and future. That makes value delivery independent +// of StrictMode's mount → cleanup → mount cycle (which re-dispatches against a fresh store): no +// matter which dispatch SWR ends up bound to, it settles to the latched outcome. +function createMockInitialValueSource(): { + fn: jest.Mock; + reject(error: unknown): void; + resolve(response: SolanaRpcResponse): void; + source: ReactiveActionSource>; +} { + type Settled = { error: unknown; type: 'reject' } | { response: SolanaRpcResponse; type: 'resolve' }; + let settled: Settled | undefined; + const pending: { reject(error: unknown): void; resolve(response: SolanaRpcResponse): void }[] = []; + const fn = jest.fn().mockImplementation(() => { + const { promise, resolve, reject } = Promise.withResolvers>(); + if (settled?.type === 'resolve') resolve(settled.response); + else if (settled?.type === 'reject') reject(settled.error); + else pending.push({ reject, resolve }); + return promise; + }); + return { + fn, + reject(error) { + settled = { error, type: 'reject' }; + pending.splice(0).forEach(p => p.reject(error)); + }, + resolve(response) { + settled = { response, type: 'resolve' }; + pending.splice(0).forEach(p => p.resolve(response)); + }, + source: { reactiveStore: () => createReactiveActionStore(fn) }, + }; +} + +// Backs `streamSource` with a real `ReactiveStreamStore` built from a mock `DataPublisher` factory. +// Delivered notifications are logged and replayed to any publisher that subscribes later, so — like +// the initial-value mock — values reach whichever connection survives StrictMode's remount. Tracks +// the net number of active (non-aborted) connections. +function createMockStreamSource(): { + activeConnections: () => number; + createDataPublisher: jest.Mock; + error(err: unknown): void; + pushNotification(notification: SolanaRpcResponse): void; + source: ReactiveStreamSource>; +} { + let activeCount = 0; + const listeners: { channel: string; listener: (payload: unknown) => void; options?: { signal?: AbortSignal } }[] = + []; + const log: { channel: string; payload: unknown }[] = []; + const createDataPublisher = jest.fn().mockImplementation(() => { + activeCount++; + let cleanedUp = false; + const cleanup = () => { + if (!cleanedUp) { + cleanedUp = true; + activeCount--; + } + }; + return Promise.resolve({ + on(channel: string, listener: (payload: unknown) => void, options?: { signal?: AbortSignal }) { + const entry = { channel, listener, options }; + listeners.push(entry); + options?.signal?.addEventListener('abort', cleanup, { once: true }); + log.filter(e => e.channel === channel).forEach(e => { + if (!options?.signal?.aborted) listener(e.payload); + }); + return () => { + const idx = listeners.indexOf(entry); + if (idx !== -1) listeners.splice(idx, 1); + }; + }, + }); + }); + const deliver = (channel: string, payload: unknown) => { + log.push({ channel, payload }); + listeners.filter(l => l.channel === channel && !l.options?.signal?.aborted).forEach(l => l.listener(payload)); + }; + return { + activeConnections: () => activeCount, + createDataPublisher, + error: err => deliver('error', err), + pushNotification: notification => deliver('data', notification), + source: { + reactiveStore: () => + createReactiveStoreFromDataPublisherFactory>({ + createDataPublisher, + dataChannelName: 'data', + errorChannelName: 'error', + }), + }, + }; +} + +type Spec = TrackedDataSpec; +function makeSpec(): { + activeConnections: () => number; + error: (err: unknown) => void; + pushNotification: (notification: SolanaRpcResponse) => void; + rejectRpc: (error: unknown) => void; + resolveRpc: (response: SolanaRpcResponse) => void; + rpcSendCalls: () => number; + spec: Spec; + subscribeCalls: () => number; +} { + const initialValue = createMockInitialValueSource(); + const stream = createMockStreamSource(); + return { + activeConnections: stream.activeConnections, + error: stream.error, + pushNotification: stream.pushNotification, + rejectRpc: initialValue.reject, + resolveRpc: initialValue.resolve, + rpcSendCalls: () => initialValue.fn.mock.calls.length, + spec: { + initialValueMapper: v => v.count, + initialValueSource: initialValue.source, + streamSource: stream.source, + streamValueMapper: v => v.count, + }, + subscribeCalls: () => stream.createDataPublisher.mock.calls.length, + }; +} + +async function waitToSettle() { + await act(async () => { + await Promise.resolve(); + }); +} + +// Run a synchronous mock trigger inside `act` and flush the microtask on which the store schedules +// its resulting state update, so the `next()` call lands while wrapped in `act`. +async function drive(trigger: () => void) { + await act(async () => { + trigger(); + await Promise.resolve(); + }); +} + +describe('useTrackedDataSWR', () => { + it('surfaces the initial RPC value as the `SolanaRpcResponse` envelope', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(['balance'], fake.spec), { wrapper }); + expect(result.current.data).toBeUndefined(); + + await drive(() => fake.resolveRpc(rpcResponse(100, { count: 42 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 42 })); + }); + + it('promotes a newer subscription notification over the initial RPC', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(['promote'], fake.spec), { wrapper }); + expect(result.current.data).toBeUndefined(); + + await drive(() => fake.resolveRpc(rpcResponse(100, { count: 1 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 })); + + await drive(() => fake.pushNotification(rpcResponse(200, { count: 2 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 200n }, value: 2 })); + }); + + it('surfaces a rejected initial RPC as result.error', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(['rpc-error'], fake.spec), { wrapper }); + expect(result.current.error).toBeUndefined(); + + const boom = new Error('rpc failed'); + await drive(() => fake.rejectRpc(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + }); + + it('surfaces a subscription error as result.error', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(['sub-error'], fake.spec), { wrapper }); + expect(result.current.error).toBeUndefined(); + + const boom = new Error('subscription failed'); + await drive(() => fake.error(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + }); + + it('skips when the key is null', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(null, fake.spec), { wrapper }); + await waitToSettle(); + expect(result.current.data).toBeUndefined(); + expect(fake.rpcSendCalls()).toBe(0); + }); + + it('skips when the spec is null (even if the key is non-null)', async () => { + const { result } = renderHook(() => useTrackedDataSWR(['no-spec'], null), { + wrapper, + }); + await waitToSettle(); + expect(result.current.data).toBeUndefined(); + }); + + it('starts when the spec transitions from null to a real spec', async () => { + const fake = makeSpec(); + const initialProps: { spec: Spec | null } = { spec: null }; + const { result, rerender } = renderHook(({ spec }) => useTrackedDataSWR(['spec-set'], spec), { + initialProps, + wrapper, + }); + await waitToSettle(); + expect(fake.rpcSendCalls()).toBe(0); + expect(result.current.data).toBeUndefined(); + + rerender({ spec: fake.spec }); + await drive(() => fake.resolveRpc(rpcResponse(100, { count: 1 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 })); + }); + + it('tears down the subscription when the spec transitions to null', async () => { + const fake = makeSpec(); + let spec: Spec | null = fake.spec; + const { rerender } = renderHook(() => useTrackedDataSWR(['spec-null'], spec), { wrapper }); + await waitFor(() => expect(fake.activeConnections()).toBe(1)); + + spec = null; + rerender(); + await waitFor(() => expect(fake.activeConnections()).toBe(0)); + }); + + it('tears down the old store and opens a new one when the key changes', async () => { + const fake = makeSpec(); + let key = 'a'; + const { rerender } = renderHook(() => useTrackedDataSWR([key], fake.spec), { wrapper }); + await waitFor(() => expect(fake.activeConnections()).toBe(1)); + const sendsBeforeKeyChange = fake.rpcSendCalls(); + + key = 'b'; + rerender(); + await waitFor(() => expect(fake.rpcSendCalls()).toBeGreaterThan(sendsBeforeKeyChange)); + await waitFor(() => expect(fake.activeConnections()).toBe(1)); + }); + + it('shares one active underlying store across components with the same key', async () => { + const fake = makeSpec(); + renderHook( + () => [useTrackedDataSWR(['shared'], fake.spec), useTrackedDataSWR(['shared'], fake.spec)] as const, + { wrapper }, + ); + await waitFor(() => expect(fake.activeConnections()).toBe(1)); + }); + + it('drops a stale subscription notification with a slot older than the current value', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(['stale-slot'], fake.spec), { wrapper }); + expect(result.current.data).toBeUndefined(); + + await drive(() => fake.resolveRpc(rpcResponse(200, { count: 99 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 200n }, value: 99 })); + + // Older slot — the underlying store ignores it; the UI keeps the newer value. + await drive(() => fake.pushNotification(rpcResponse(150, { count: 7 }))); + await waitToSettle(); + expect(result.current.data).toStrictEqual({ context: { slot: 200n }, value: 99 }); + }); + + it('surfaces a later subscription error while retaining the loaded value', async () => { + const fake = makeSpec(); + const { result } = renderHook(() => useTrackedDataSWR(['data-on-error'], fake.spec), { wrapper }); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + + await drive(() => fake.resolveRpc(rpcResponse(100, { count: 7 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 7 })); + + const boom = new Error('subscription failed after load'); + await drive(() => fake.error(boom)); + await waitFor(() => expect(result.current.error).toBe(boom)); + // The prior value is retained alongside the surfaced error. + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 7 }); + }); + + it('opens the store when the key transitions from null to non-null', async () => { + const fake = makeSpec(); + const initialProps: { key: string | null } = { key: null }; + const { result, rerender } = renderHook( + ({ key }) => useTrackedDataSWR(key == null ? null : ['key-set', key], fake.spec), + { initialProps, wrapper }, + ); + await waitToSettle(); + expect(fake.rpcSendCalls()).toBe(0); + expect(result.current.data).toBeUndefined(); + + rerender({ key: 'ready' }); + await drive(() => fake.resolveRpc(rpcResponse(100, { count: 1 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 })); + }); + + it('keeps the current store when the spec identity changes but the key is stable', async () => { + // SWR keys the cache on the SWR key, and `subscribe` reads the latest spec from a ref — so a + // new spec object under a stable key must NOT rebuild the store or re-fire the sources. This + // mirrors `useSubscriptionSWR`'s "source changes but key is stable" guarantee. + const a = makeSpec(); + const b = makeSpec(); + let spec = a.spec; + const { result, rerender } = renderHook(() => useTrackedDataSWR(['stable-key'], spec), { wrapper }); + expect(result.current.data).toBeUndefined(); + await drive(() => a.resolveRpc(rpcResponse(100, { count: 1 }))); + await waitFor(() => expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 })); + + spec = b.spec; + rerender(); + await waitToSettle(); + // The new spec's sources never fire, and the original connection stays live. + expect(b.rpcSendCalls()).toBe(0); + expect(b.subscribeCalls()).toBe(0); + expect(a.activeConnections()).toBe(1); + expect(result.current.data).toStrictEqual({ context: { slot: 100n }, value: 1 }); + }); + + it('does not let an older-slot fetch on remount regress the warmer cached value', async () => { + // SWR's cache outlives the per-subscription store, but the store's slot high-water mark + // does not — a fresh store starts its window at -1. On a remount with a warm cache, a + // lagging RPC node can resolve the initial fetch at an older slot than the one already + // cached; that must not overwrite the newer cached envelope. + const PersistentWrapper = makePersistentWrapper(); + + const first = makeSpec(); + const mountA = renderHook(() => useTrackedDataSWR(['remount'], first.spec), { wrapper: PersistentWrapper }); + expect(mountA.result.current.data).toBeUndefined(); + await drive(() => first.resolveRpc(rpcResponse(200, { count: 1 }))); + await waitFor(() => expect(mountA.result.current.data).toStrictEqual({ context: { slot: 200n }, value: 1 })); + mountA.unmount(); + + // Remount under the same key with a fresh spec whose RPC node lags behind. + const second = makeSpec(); + const mountB = renderHook(() => useTrackedDataSWR(['remount'], second.spec), { wrapper: PersistentWrapper }); + // The remount paints immediately from the warm cache. + expect(mountB.result.current.data).toStrictEqual({ context: { slot: 200n }, value: 1 }); + + await drive(() => second.resolveRpc(rpcResponse(150, { count: 2 }))); + await waitToSettle(); + // The older-slot fetch is suppressed; the newer cached value stands. + expect(mountB.result.current.data).toStrictEqual({ context: { slot: 200n }, value: 1 }); + + // A genuinely newer notification still flows through. + await drive(() => second.pushNotification(rpcResponse(300, { count: 3 }))); + await waitFor(() => expect(mountB.result.current.data).toStrictEqual({ context: { slot: 300n }, value: 3 })); + }); + + describe('SSR', () => { + it('renders without firing the RPC or subscription', () => { + const fake = makeSpec(); + function Component() { + const { data } = useTrackedDataSWR(['ssr'], fake.spec); + return

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

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

no-data

'); + expect(fake.rpcSendCalls()).toBe(0); + expect(fake.subscribeCalls()).toBe(0); + }); + }); +}); diff --git a/packages/react/src/swr/__typetests__/useTrackedDataSWR-typetest.ts b/packages/react/src/swr/__typetests__/useTrackedDataSWR-typetest.ts new file mode 100644 index 000000000..5d65d25f5 --- /dev/null +++ b/packages/react/src/swr/__typetests__/useTrackedDataSWR-typetest.ts @@ -0,0 +1,75 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { SolanaRpcResponse } from '@solana/kit'; +import type { SWRSubscriptionResponse } from 'swr/subscription'; + +import type { TrackedDataSpec } from '../../useTrackedData'; +import { useTrackedDataSWR } from '../useTrackedDataSWR'; + +const spec = null as unknown as TrackedDataSpec<{ a: number }, { b: number }, number>; + +// [DESCRIBE] useTrackedDataSWR +{ + // `data` is the underlying primitive's guaranteed `SolanaRpcResponse` envelope. Callers + // read data.value and data.context.slot directly. + { + const result = useTrackedDataSWR(['balance'], spec); + result.data satisfies SolanaRpcResponse | undefined; + result.data?.value satisfies number | undefined; + result.data?.context.slot satisfies bigint | undefined; + } + + // Null key disables. + { + useTrackedDataSWR(null, spec) satisfies SWRSubscriptionResponse, unknown>; + } + + // Null spec disables. + { + useTrackedDataSWR<{ a: number }, { b: number }, number>(['balance'], null) satisfies SWRSubscriptionResponse< + SolanaRpcResponse, + unknown + >; + } + + // Default error type is unknown. + { + const { error } = useTrackedDataSWR(['balance'], spec); + error satisfies unknown; + // @ts-expect-error - errors default to unknown, not Error. + error satisfies Error | undefined; + } + + // TError is overridable via the generic. + { + useTrackedDataSWR<{ a: number }, { b: number }, number, string>(['balance'], spec).error satisfies + | string + | undefined; + } + + // Options are forwarded to SWR's configuration and typed against the envelope data. + { + useTrackedDataSWR(['balance'], spec, { + fallbackData: { context: { slot: 1n }, value: 1 }, + // @ts-expect-error - SWR doesn't accept arbitrary keys. + notARealOption: true, + onSuccess(data) { + data.value satisfies number; + data.context.slot satisfies bigint; + }, + revalidateOnFocus: false, + }); + useTrackedDataSWR(['balance'], spec, { + // @ts-expect-error - fallback data must match the envelope shape. + fallbackData: { nope: 1 }, + }); + } + + // This hook does not accept a getAbortSignal option (lifetime is controlled via the key). + { + useTrackedDataSWR(['balance'], spec, { + // @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/useTrackedDataSWR.ts b/packages/react/src/swr/useTrackedDataSWR.ts new file mode 100644 index 000000000..6ecb40628 --- /dev/null +++ b/packages/react/src/swr/useTrackedDataSWR.ts @@ -0,0 +1,126 @@ +import { createReactiveStoreWithInitialValueAndSlotTracking, type SolanaRpcResponse } 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 { useLatest } from '../useLatest'; +import type { TrackedDataSpec } from '../useTrackedData'; + +/** + * SWR-backed counterpart to `useTrackedData`. Pairs a one-shot RPC fetch with an ongoing + * subscription (slot-deduped by the underlying Kit primitive) and routes the unified stream + * through SWR's subscription cache so components reading the same `key` share one underlying + * connection and participate in SWR's cache and devtools. + * + * Returns SWR's native `{ data, error }` shape. `data` is the `SolanaRpcResponse` envelope + * emitted by the underlying kit primitive — the primitive's type guarantees the envelope shape, + * so callers can read `data.value` (the unified item produced by the spec's mappers) and + * `data.context.slot` (the slot the store dedup'd on) directly. + * + * Pass `null` for `key` or `spec` to disable. Mirrors `useTrackedData`'s nullable-spec pattern. + * + * SWR subscriptions surface only `{ data, error }`, so this hook has no `refresh` and no + * `getAbortSignal` option. To stop or restart the subscription, toggle the `key` to/from `null`. + * + * If the `spec` changes but the SWR `key` is stable, the existing connection stays bound to + * the original spec — SWR caches on `key`, and `subscribe` reads the spec from a ref. + * Bump the `key` to swap specs. + * + * Slot dedupe spans the SWR cache, not just one store. The underlying primitive tracks a slot + * high-water mark per store, but that mark dies when the store is disposed (unmount, key change) + * while the SWR cache entry survives. This hook bridges that gap: a remount's fresh store cannot + * regress the cached envelope to an older slot — e.g. a lagging RPC node resolving the initial + * fetch behind the cached value is refused, and the warmer cached value stands until something + * newer arrives. + * + * @typeParam TInitialValue - The value inside the initial RPC `SolanaRpcResponse` envelope. + * @typeParam TStreamValue - The value inside subscription `SolanaRpcResponse` notifications. + * @typeParam TItem - The unified item type produced by the two mappers and surfaced as `data.value`. + * @typeParam TError - The error type SWR will surface on failure. + * + * @example + * ```tsx + * function AccountBalance({ address }: { address: Address }) { + * const client = useClient & ClientWithRpcSubscriptions>(); + * const spec = useMemo(() => address ? ({ + * initialValueSource: client.rpc.getBalance(address), + * initialValueMapper: (lamports: bigint) => lamports, + * streamSource: client.rpcSubscriptions.accountNotifications(address), + * streamValueMapper: ({ lamports }: { lamports: bigint }) => lamports, + * }) : null, [client, address]); + * const { data } = useTrackedDataSWR(address ? ['balance', address] : null, spec); + * return

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

; + * } + * ``` + */ +export function useTrackedDataSWR( + key: SWRKey, + spec: TrackedDataSpec | null, + options?: SWRConfiguration, TError>, +): SWRSubscriptionResponse, TError> { + const swrConfig = options ?? {}; + + // Ref-sync the spec so an inline value passed each render doesn't change the `subscribe` + // callback's identity. `subscribe` reads the latest spec from the ref when SWR invokes it. + const specRef = useLatest(spec); + + // Mirror of the envelope SWR currently holds for this key. The per-subscription store tracks a + // slot high-water mark internally, but that mark dies with the store while the SWR cache entry + // survives — so on a remount a fresh store (high-water reset to -1) would happily forward a + // value from a lagging RPC node at an older slot than the one already cached, regressing it. + // The bridge reads this ref to refuse any value older than what SWR already holds. Synced after + // commit (below), so on a remount it reflects the warm-cache value before the fresh store's + // async initial fetch can resolve. + const cachedDataRef = useRef | undefined>(undefined); + + const subscribe = useCallback( + (_key: SWRKey, { next }: SWRSubscriptionOptions, TError>) => { + const current = specRef.current; + // defensive - we set key to null when spec is null, so `subscribe` shouldn't be called + // with a null spec + if (!current) return () => {}; + const store = createReactiveStoreWithInitialValueAndSlotTracking(current); + const unsubscribe = store.subscribe(() => { + const state = store.getUnifiedState(); + if (state.status === 'loaded') { + // Guard the store/cache boundary: a fresh store's high-water mark starts at -1, + // so without this check a remount could regress the cache to an older slot than + // it already holds. The bridge fires asynchronously (network/notification), well + // after the post-commit sync below has populated `cachedDataRef`, so a warm + // remount sees the cached slot here. + const cachedSlot = cachedDataRef.current?.context.slot ?? -1n; + if (state.data.context.slot < cachedSlot) return; + // The kit primitive always emits `SolanaRpcResponse` — pass it through. + next(null, state.data); + } else if (state.status === 'error') { + next(state.error as TError); + } + }); + store.connect(); + return () => { + unsubscribe(); + store.reset(); + }; + // `specRef` and `cachedDataRef` are stable refs, so listing `specRef` leaves + // `subscribe` referentially stable across renders — SWR keys off `key`, not the + // subscribe identity, but a stable callback avoids needless re-subscription churn. + }, + [specRef], + ); + + // Force the key to `null` when there's no spec — either-null disables the subscription. + const result = useSWRSubscription, TError, SWRKey>( + spec == null ? null : key, + subscribe, + swrConfig, + ); + + // Keep the slot floor in sync with what SWR currently holds. Forwarded values only ever advance + // the cached slot (the bridge refuses older ones), so this ref is monotonic per key. + useIsomorphicLayoutEffect(() => { + cachedDataRef.current = result.data; + }); + + return result; +}