Skip to content

Commit 176febd

Browse files
committed
Drop auto-connect from stream stores; callers invoke connect() explicitly
`createReactiveStoreFromDataPublisherFactory` (and `createReactiveStoreWithInitialValueAndSlotTracking`) no longer auto-connect on construction. The returned store starts in `status: 'idle'`; the caller calls `store.connect()` to fire the underlying request/subscription, mirroring how `ReactiveActionStore` requires an explicit `dispatch()`. This unifies the lifecycle model across both primitives — stores are state machines that callers orchestrate, not self-starting subscriptions — and lets `useSubscription` adopt the same `useEffect(() => { store.connect(); return () => store.reset(); })` pattern that `useRequest` uses today. `ReactiveStreamStore<T>` gains two new methods: `connect()` (open or re-open the stream — transitions through `loading` from `idle` or `retrying` from any other status while preserving stale data) and `reset()` (abort the current connection, clearing `data` and `error`, and return to `idle`). The state union grows an `idle` variant. `retry()` becomes a deprecated alias that delegates to `connect()` guarded by `status === 'error'` — preserving its original error-only behaviour for callers who relied on it, but new code should call `connect()` directly. `createReactiveStoreFromDataPublisher` (the non-factory variant accepting a ready-made `DataPublisher`) is removed. Its only documented consumer was the deprecated `PendingRpcSubscriptionsRequest.reactive()` method, which is also removed in this commit. Both have been deprecated for some time; the cascade removal lands them together rather than ratcheting through two more release cycles. Code that built a store around a singleton publisher should wrap it in `createDataPublisher: () => Promise.resolve(publisher)` and use the factory variant. `createReactiveStoreWithInitialValueAndSlotTracking` is also lazy: starts in `idle`, requires `connect()` to fire the RPC request and open the subscription. The `lastUpdateSlot` window resets on `reset()` so a fresh `connect()` starts a new slot-tracking lifecycle. Tests in `@solana/subscribable`, `@solana/rpc-subscriptions-spec`, and `@solana/kit` updated accordingly (the `reactive()` test block in `rpc-subscriptions-spec` is removed). The error code `SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED` is left in the codes table per the never-remove-error-codes rule, but is no longer produced by any code path in this repo.
1 parent 1f3b1fc commit 176febd

10 files changed

Lines changed: 572 additions & 720 deletions

.changeset/bumpy-seas-melt.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@solana/subscribable': major
3+
'@solana/rpc-subscriptions-spec': major
4+
'@solana/kit': major
5+
---
6+
7+
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.
8+
9+
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.
10+
11+
```ts
12+
const store = createReactiveStoreFromDataPublisherFactory({
13+
abortSignal,
14+
createDataPublisher,
15+
dataChannelName: 'notification',
16+
errorChannelName: 'error',
17+
});
18+
store.connect(); // opens the stream — previously this happened on construction
19+
```
20+
21+
`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.
22+
23+
`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.
24+
25+
`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.

packages/kit/src/__tests__/create-async-generator-with-initial-value-and-slot-tracking-test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ function createMockSubscriptionRequest(): {
9393
complete,
9494
error,
9595
mockRequest: {
96-
reactive: jest.fn().mockRejectedValue(new Error('not implemented')),
9796
reactiveStore: jest.fn().mockImplementation(() => {
9897
throw new Error('not implemented');
9998
}),

packages/kit/src/__tests__/create-reactive-store-with-initial-value-and-slot-tracking-test.ts

Lines changed: 39 additions & 4 deletions
Large diffs are not rendered by default.

packages/kit/src/create-reactive-store-with-initial-value-and-slot-tracking.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import type { ReactiveState, ReactiveStreamStore } from '@solana/subscribable';
1616
*/
1717
export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TRpcValue, TSubscriptionValue, TItem> = Readonly<{
1818
/**
19-
* Triggering this abort signal will cancel the pending RPC request and subscription, and
20-
* disconnect the store from further updates.
19+
* Triggering this abort signal will cancel any in-flight RPC request and subscription, and
20+
* permanently disconnect the store from further updates. Subsequent
21+
* {@link ReactiveStreamStore.connect | `connect()`} calls become no-ops.
2122
*/
2223
abortSignal: AbortSignal;
2324
/**
@@ -42,10 +43,10 @@ export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TRpcValue,
4243
rpcValueMapper: (value: TRpcValue) => TItem;
4344
}>;
4445

45-
const LOADING_STATE: ReactiveState<never> = Object.freeze({
46+
const IDLE_STATE: ReactiveState<never> = Object.freeze({
4647
data: undefined,
4748
error: undefined,
48-
status: 'loading',
49+
status: 'idle',
4950
});
5051

5152
/**
@@ -59,15 +60,20 @@ const LOADING_STATE: ReactiveState<never> = Object.freeze({
5960
*
6061
* Things to note:
6162
*
62-
* - `getUnifiedState()` starts in `status: 'loading'` until the first response or notification
63-
* arrives. Once data arrives it transitions to `status: 'loaded'` with a
64-
* {@link SolanaRpcResponse} containing the value and the slot context at which it was observed.
63+
* - The returned store starts in `status: 'idle'`. Call
64+
* {@link ReactiveStreamStore.connect | `connect()`} to fire the RPC request and open the
65+
* subscription.
66+
* - From `idle`, the store transitions through `loading` until the first response or notification
67+
* arrives, then to `loaded` with a {@link SolanaRpcResponse} containing the value and the slot
68+
* context at which it was observed.
6569
* - On error from either source, the store transitions to `status: 'error'` preserving the last
6670
* known value. Only the first error per connection window is captured.
67-
* - Calling {@link ReactiveStreamStore.retry | `retry()`} while in `status: 'error'` re-sends the RPC
68-
* request and re-subscribes to the subscription using a fresh inner abort signal. The store
69-
* transitions through `status: 'retrying'` back to `loaded`/`error`.
70-
* - Triggering the caller's abort signal disconnects the store permanently; subsequent `retry()`
71+
* - A subsequent `connect()` after `loaded` or `error` aborts the current connection, transitions
72+
* through `status: 'retrying'` (preserving stale data), and re-fires the RPC request and
73+
* subscription with a fresh inner abort signal.
74+
* - {@link ReactiveStreamStore.reset | `reset()`} aborts the current connection and returns the
75+
* store to `idle`, clearing `data` and `error`.
76+
* - Triggering the caller's `abortSignal` disconnects the store permanently; subsequent `connect()`
7177
* calls are no-ops.
7278
*
7379
* @param config
@@ -97,11 +103,12 @@ const LOADING_STATE: ReactiveState<never> = Object.freeze({
97103
* const state = balanceStore.getUnifiedState();
98104
* if (state.status === 'error') {
99105
* console.error('Error:', state.error);
100-
* balanceStore.retry();
106+
* balanceStore.connect();
101107
* } else if (state.status === 'loaded') {
102108
* console.log(`Balance at slot ${state.data.context.slot}:`, state.data.value);
103109
* }
104110
* });
111+
* balanceStore.connect();
105112
* ```
106113
*
107114
* @see {@link ReactiveStreamStore}
@@ -115,8 +122,9 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
115122
}: CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TRpcValue, TSubscriptionValue, TItem>): ReactiveStreamStore<
116123
SolanaRpcResponse<TItem>
117124
> {
118-
let currentState: ReactiveState<SolanaRpcResponse<TItem>> = LOADING_STATE;
125+
let currentState: ReactiveState<SolanaRpcResponse<TItem>> = IDLE_STATE;
119126
let lastUpdateSlot = -1n;
127+
let currentInnerController: AbortController | undefined;
120128
const subscribers = new Set<() => void>();
121129

122130
const outerController = new AbortController();
@@ -126,24 +134,44 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
126134
subscribers.forEach(cb => cb());
127135
}
128136

129-
function connect() {
137+
function setState(next: ReactiveState<SolanaRpcResponse<TItem>>) {
138+
if (
139+
currentState.status === next.status &&
140+
currentState.data === next.data &&
141+
currentState.error === next.error
142+
) {
143+
return;
144+
}
145+
currentState = next;
146+
notify();
147+
}
148+
149+
function performConnect() {
130150
if (outerController.signal.aborted) return;
151+
// Abort any currently active connection before starting a fresh one.
152+
currentInnerController?.abort();
153+
// Transition based on whether we have prior data/error to preserve.
154+
if (currentState.status === 'idle') {
155+
setState({ data: undefined, error: undefined, status: 'loading' });
156+
} else {
157+
setState({ data: currentState.data, error: undefined, status: 'retrying' });
158+
}
159+
131160
const innerController = new AbortController();
161+
currentInnerController = innerController;
132162
const forwardAbort = () => innerController.abort(outerController.signal.reason);
133163
outerController.signal.addEventListener('abort', forwardAbort, { signal: innerController.signal });
134164
const innerSignal = innerController.signal;
135165

136166
function handleError(err: unknown) {
137167
if (innerSignal.aborted) return;
138168
if (currentState.status === 'error') return;
139-
currentState = { data: currentState.data, error: err, status: 'error' };
169+
setState({ data: currentState.data, error: err, status: 'error' });
140170
innerController.abort(err);
141-
notify();
142171
}
143172

144173
function handleValue(value: SolanaRpcResponse<TItem>) {
145-
currentState = { data: value, error: undefined, status: 'loaded' };
146-
notify();
174+
setState({ data: value, error: undefined, status: 'loaded' });
147175
}
148176

149177
rpcRequest
@@ -175,9 +203,16 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
175203
.catch(handleError);
176204
}
177205

178-
connect();
206+
function performReset() {
207+
currentInnerController?.abort();
208+
currentInnerController = undefined;
209+
// `lastUpdateSlot` resets too — a fresh connect() starts a new slot window.
210+
lastUpdateSlot = -1n;
211+
setState(IDLE_STATE);
212+
}
179213

180214
return {
215+
connect: performConnect,
181216
getError(): unknown {
182217
return currentState.error;
183218
},
@@ -187,12 +222,10 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
187222
getUnifiedState(): ReactiveState<SolanaRpcResponse<TItem>> {
188223
return currentState;
189224
},
225+
reset: performReset,
190226
retry(): void {
191-
if (outerController.signal.aborted) return;
192227
if (currentState.status !== 'error') return;
193-
currentState = { data: currentState.data, error: undefined, status: 'retrying' };
194-
notify();
195-
connect();
228+
performConnect();
196229
},
197230
subscribe(callback: () => void): () => void {
198231
subscribers.add(callback);

packages/rpc-subscriptions-spec/src/__tests__/rpc-subscriptions-test.ts

Lines changed: 26 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -34,74 +34,6 @@ describe('createSubscriptionRpc', () => {
3434
});
3535
});
3636

37-
describe('PendingRpcSubscriptionsRequest.reactive()', () => {
38-
let mockTransport: jest.MockedFunction<RpcSubscriptionsTransport>;
39-
let mockOn: jest.Mock;
40-
let mockDataPublisher: DataPublisher;
41-
let rpcSubscriptions: RpcSubscriptions<TestRpcSubscriptionNotifications>;
42-
function publish(type: string, payload: unknown) {
43-
mockOn.mock.calls.filter(([actualType]) => actualType === type).forEach(([_, listener]) => listener(payload));
44-
}
45-
beforeEach(() => {
46-
mockOn = jest.fn().mockReturnValue(function unsubscribe() {});
47-
mockDataPublisher = { on: mockOn };
48-
mockTransport = jest.fn().mockResolvedValue(mockDataPublisher);
49-
rpcSubscriptions = createSubscriptionRpc<TestRpcSubscriptionNotifications>({
50-
api: {
51-
thingNotifications(...args: unknown[]) {
52-
return {
53-
execute: jest.fn().mockResolvedValue(mockDataPublisher),
54-
request: { methodName: 'thingNotifications', params: args },
55-
};
56-
},
57-
},
58-
transport: mockTransport,
59-
});
60-
});
61-
62-
it('passes the abort signal to the transport', async () => {
63-
expect.assertions(1);
64-
const abortController = new AbortController();
65-
await rpcSubscriptions.thingNotifications().reactive({ abortSignal: abortController.signal });
66-
expect(mockTransport).toHaveBeenCalledWith(expect.objectContaining({ signal: abortController.signal }));
67-
});
68-
it('returns a store whose getState() starts as undefined', async () => {
69-
expect.assertions(1);
70-
const store = await rpcSubscriptions
71-
.thingNotifications()
72-
.reactive({ abortSignal: new AbortController().signal });
73-
expect(store.getState()).toBeUndefined();
74-
});
75-
it('returns a store whose getState() reflects incoming notifications', async () => {
76-
expect.assertions(1);
77-
const store = await rpcSubscriptions
78-
.thingNotifications()
79-
.reactive({ abortSignal: new AbortController().signal });
80-
publish('notification', { value: 42 });
81-
expect(store.getState()).toStrictEqual({ value: 42 });
82-
});
83-
it('calls store subscribers when a notification arrives', async () => {
84-
expect.assertions(1);
85-
const store = await rpcSubscriptions
86-
.thingNotifications()
87-
.reactive({ abortSignal: new AbortController().signal });
88-
const subscriber = jest.fn();
89-
store.subscribe(subscriber);
90-
publish('notification', { value: 42 });
91-
expect(subscriber).toHaveBeenCalledTimes(1);
92-
});
93-
it('surfaces errors via getError()', async () => {
94-
expect.assertions(2);
95-
const store = await rpcSubscriptions
96-
.thingNotifications()
97-
.reactive({ abortSignal: new AbortController().signal });
98-
expect(store.getError()).toBeUndefined();
99-
const error = new Error('o no');
100-
publish('error', error);
101-
expect(store.getError()).toBe(error);
102-
});
103-
});
104-
10537
describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
10638
let mockTransport: jest.MockedFunction<RpcSubscriptionsTransport>;
10739
let mockOn: jest.Mock;
@@ -133,28 +65,42 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
13365
});
13466
});
13567

136-
it('passes the abort signal to the transport', async () => {
68+
it('returns a store that starts in `idle` status and does not call the transport before connect()', () => {
69+
const store = rpcSubscriptions
70+
.thingNotifications()
71+
.reactiveStore({ abortSignal: new AbortController().signal });
72+
expect(store.getUnifiedState()).toStrictEqual({
73+
data: undefined,
74+
error: undefined,
75+
status: 'idle',
76+
});
77+
expect(mockTransport).not.toHaveBeenCalled();
78+
});
79+
it('passes the abort signal to the transport on connect()', async () => {
13780
expect.assertions(1);
13881
const abortController = new AbortController();
139-
rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal });
82+
const store = rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal });
83+
store.connect();
14084
await flushMicrotasks();
14185
expect(mockTransport).toHaveBeenCalledWith(expect.objectContaining({ signal: abortController.signal }));
14286
});
143-
it('returns a store that starts in `loading` status before the transport resolves', () => {
87+
it('transitions to `loading` after connect() before the transport resolves', () => {
14488
const store = rpcSubscriptions
14589
.thingNotifications()
14690
.reactiveStore({ abortSignal: new AbortController().signal });
91+
store.connect();
14792
expect(store.getUnifiedState()).toStrictEqual({
14893
data: undefined,
14994
error: undefined,
15095
status: 'loading',
15196
});
15297
});
153-
it('returns a store whose state reflects incoming notifications', async () => {
98+
it('reflects incoming notifications after connect()', async () => {
15499
expect.assertions(1);
155100
const store = rpcSubscriptions
156101
.thingNotifications()
157102
.reactiveStore({ abortSignal: new AbortController().signal });
103+
store.connect();
158104
await flushMicrotasks();
159105
publish('notification', { value: 42 });
160106
expect(store.getUnifiedState()).toStrictEqual({
@@ -168,6 +114,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
168114
const store = rpcSubscriptions
169115
.thingNotifications()
170116
.reactiveStore({ abortSignal: new AbortController().signal });
117+
store.connect();
171118
await flushMicrotasks();
172119
const subscriber = jest.fn();
173120
store.subscribe(subscriber);
@@ -179,6 +126,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
179126
const store = rpcSubscriptions
180127
.thingNotifications()
181128
.reactiveStore({ abortSignal: new AbortController().signal });
129+
store.connect();
182130
await flushMicrotasks();
183131
const error = new Error('o no');
184132
publish('error', error);
@@ -188,21 +136,23 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
188136
status: 'error',
189137
});
190138
});
191-
it('re-invokes the transport on retry() after an error', async () => {
139+
it('re-invokes the transport on connect() after an error', async () => {
192140
expect.assertions(1);
193141
const store = rpcSubscriptions
194142
.thingNotifications()
195143
.reactiveStore({ abortSignal: new AbortController().signal });
144+
store.connect();
196145
await flushMicrotasks();
197146
publish('error', new Error('stream died'));
198-
store.retry();
147+
store.connect();
199148
await flushMicrotasks();
200149
expect(mockTransport).toHaveBeenCalledTimes(2);
201150
});
202151
it('aborts the signal forwarded to the data publisher listeners when the caller aborts', async () => {
203152
expect.assertions(2);
204153
const abortController = new AbortController();
205-
rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal });
154+
const store = rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal });
155+
store.connect();
206156
await flushMicrotasks();
207157
const onCall = mockOn.mock.calls.find(([channel]: [string]) => channel === 'notification');
208158
const listenerSignal = (onCall![2] as { signal: AbortSignal }).signal;
@@ -215,6 +165,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
215165
const store = rpcSubscriptions
216166
.thingNotifications()
217167
.reactiveStore({ abortSignal: new AbortController().signal });
168+
store.connect();
218169
await flushMicrotasks();
219170
expect(store.getUnifiedState()).toBe(store.getUnifiedState());
220171
publish('notification', { value: 42 });

0 commit comments

Comments
 (0)