Skip to content

Commit ade3616

Browse files
committed
Add withSignal() to ReactiveStreamStore for per-connection cancellation
Adds `store.withSignal(signal).connect()` to `ReactiveStreamStore`, mirroring the action store's per-dispatch `withSignal()` pattern — callers attach a per-connection signal at the call site instead of baking one into the store's construction. Drops the construction-time `abortSignal` option on `createReactiveStoreFromDataPublisherFactory`, `createReactiveStoreWithInitialValueAndSlotTracking`, and `PendingRpcSubscriptionsRequest.reactiveStore()`. The duck-type `ReactiveStreamSource<T>.reactiveStore()` is now parameter-less, mirroring `ReactiveActionSource<T>.reactiveStore()`. `store.withSignal(signal)` returns a thin wrapper exposing `connect()`. Each call composes the caller-provided signal with the per-connection inner controller via `AbortSignal.any` — aborting either tears down the active connection. Aborting the caller-provided signal surfaces the abort reason on state as `{ status: 'error' }`; supersession via the internal controller (a newer `connect()` or `reset()`) stays silent so the newer call owns state. Use cases: - Per-connection timeout: `store.withSignal(AbortSignal.timeout(30_000)).connect()` mints a fresh clock per attempt. - Permanent kill switch: hold one `AbortController`, bind the wrapper once (\`const killable = store.withSignal(killCtrl.signal)\`), and use \`killable.connect()\` everywhere. After \`killCtrl.abort()\`, every future call short-circuits to error. Bare \`store.connect()\` calls bypass the bound signal — same discipline contract as the action store's bind-once pattern. \`createDataPublisher\` is widened from \`() => Promise<DataPublisher>\` to \`(signal: AbortSignal) => Promise<DataPublisher>\`. The store passes the composed per-connection signal to the factory so the underlying transport can stop on per-connection abort, not just the stream-store's listeners. Existing no-arg factories still satisfy the new shape — TypeScript allows fewer parameters than the declared type. Internal: the per-connection inner controller plus the optional caller signal are composed with \`AbortSignal.any\`, replacing the manually-scoped abort forwarder and the prior \`outerController\` that bridged the construction-time \`abortSignal\` to inner connections. Same observable behaviour for the supersede / reset paths; new behaviour for the caller-signal path (surfaces as error rather than silently disconnecting). Tests in \`@solana/subscribable\`, \`@solana/rpc-subscriptions-spec\`, and \`@solana/kit\` updated to exercise the new \`withSignal\` API. The "abort signal" describe blocks (which tested the deprecated construction-time signal) are replaced with focused \`withSignal()\` blocks covering the per-connection timeout, kill-switch, and supersede-doesn't-touch-caller-signal cases.
1 parent 345a3d2 commit ade3616

8 files changed

Lines changed: 290 additions & 300 deletions

.changeset/blue-webs-work.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@solana/subscribable': major
3+
'@solana/rpc-subscriptions-spec': major
4+
'@solana/kit': major
5+
---
6+
7+
Add `withSignal()` to `ReactiveStreamStore` for per-connection cancellation, replacing the construction-time `abortSignal` option. Mirrors the action store's per-dispatch `withSignal()` pattern — callers attach a per-connection signal at the call site instead of baking one into the store.
8+
9+
```ts
10+
const store = createReactiveStoreFromDataPublisherFactory({
11+
createDataPublisher: signal => transport({ signal, ...plan }),
12+
dataChannelName: 'notification',
13+
errorChannelName: 'error',
14+
});
15+
// Per-connection timeout — fresh clock per attempt:
16+
store.withSignal(AbortSignal.timeout(30_000)).connect();
17+
```
18+
19+
`store.withSignal(signal)` returns a thin wrapper exposing `connect()` that composes the caller-provided signal with the per-connection inner controller via `AbortSignal.any`. Aborting the caller's signal surfaces the abort reason on state as `{ status: 'error' }`; supersession via the internal controller (a newer `connect()` or `reset()`) stays silent so the newer call owns state. The "permanent kill switch" pattern is expressible by binding once: `const killable = store.withSignal(killCtrl.signal); killable.connect();`. After `killCtrl.abort()`, every `killable.connect()` short-circuits to error.
20+
21+
`createDataPublisher` is widened from `() => Promise<DataPublisher>` to `(signal: AbortSignal) => Promise<DataPublisher>`. The store passes the composed per-connection signal to the factory so the underlying transport can stop on per-connection abort, not just the stream-store's listeners. Existing no-arg factories still satisfy the new shape — TypeScript allows fewer parameters than the declared type.
22+
23+
The construction-time `abortSignal` option on `createReactiveStoreFromDataPublisherFactory`, `createReactiveStoreWithInitialValueAndSlotTracking`, and `PendingRpcSubscriptionsRequest.reactiveStore()` is removed. Callers wanting a long-lived kill switch use the bind-once `withSignal` pattern. `ReactiveStreamSource<T>.reactiveStore()` is now parameter-less (mirrors `ReactiveActionSource<T>.reactiveStore()`).

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

Lines changed: 27 additions & 126 deletions
Large diffs are not rendered by default.

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

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,6 @@ import type { ReactiveState, ReactiveStreamStore } from '@solana/subscribable';
1515
* @see {@link createReactiveStoreWithInitialValueAndSlotTracking}
1616
*/
1717
export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TRpcValue, TSubscriptionValue, TItem> = Readonly<{
18-
/**
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.
22-
*/
23-
abortSignal: AbortSignal;
2418
/**
2519
* A pending RPC request whose response will be used to set the store's initial state.
2620
* The response must be a {@link SolanaRpcResponse} so that its slot can be compared with
@@ -73,8 +67,10 @@ const IDLE_STATE: ReactiveState<never> = Object.freeze({
7367
* subscription with a fresh inner abort signal.
7468
* - {@link ReactiveStreamStore.reset | `reset()`} aborts the current connection and returns the
7569
* store to `idle`, clearing `data` and `error`.
76-
* - Triggering the caller's `abortSignal` disconnects the store permanently; subsequent `connect()`
77-
* calls are no-ops.
70+
* - Attach a caller-provided cancellation source via
71+
* {@link ReactiveStreamStore.withSignal | `withSignal()`} — `store.withSignal(signal).connect()`
72+
* composes the signal with the per-connection controller. Aborting the caller's signal
73+
* transitions the store to `error` with that abort reason.
7874
*
7975
* @param config
8076
*
@@ -92,7 +88,6 @@ const IDLE_STATE: ReactiveState<never> = Object.freeze({
9288
* const myAddress = address('FnHyam9w4NZoWR6mKN1CuGBritdsEWZQa4Z4oawLZGxa');
9389
*
9490
* const balanceStore = createReactiveStoreWithInitialValueAndSlotTracking({
95-
* abortSignal: AbortSignal.timeout(60_000),
9691
* rpcRequest: rpc.getBalance(myAddress, { commitment: 'confirmed' }),
9792
* rpcValueMapper: lamports => lamports,
9893
* rpcSubscriptionRequest: rpcSubscriptions.accountNotifications(myAddress),
@@ -108,13 +103,12 @@ const IDLE_STATE: ReactiveState<never> = Object.freeze({
108103
* console.log(`Balance at slot ${state.data.context.slot}:`, state.data.value);
109104
* }
110105
* });
111-
* balanceStore.connect();
106+
* balanceStore.withSignal(AbortSignal.timeout(60_000)).connect();
112107
* ```
113108
*
114109
* @see {@link ReactiveStreamStore}
115110
*/
116111
export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TSubscriptionValue, TItem>({
117-
abortSignal,
118112
rpcRequest,
119113
rpcValueMapper,
120114
rpcSubscriptionRequest,
@@ -127,9 +121,6 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
127121
let currentInnerController: AbortController | undefined;
128122
const subscribers = new Set<() => void>();
129123

130-
const outerController = new AbortController();
131-
abortSignal.addEventListener('abort', () => outerController.abort(abortSignal.reason));
132-
133124
function notify() {
134125
subscribers.forEach(cb => cb());
135126
}
@@ -146,10 +137,14 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
146137
notify();
147138
}
148139

149-
function performConnect() {
150-
if (outerController.signal.aborted) return;
140+
function performConnect(callerSignal: AbortSignal | undefined) {
151141
// Abort any currently active connection before starting a fresh one.
152142
currentInnerController?.abort();
143+
// If the caller's signal is already aborted, surface as error and bail.
144+
if (callerSignal?.aborted) {
145+
setState({ data: currentState.data, error: callerSignal.reason, status: 'error' });
146+
return;
147+
}
153148
// Transition based on whether we have prior data/error to preserve.
154149
if (currentState.status === 'idle') {
155150
setState({ data: undefined, error: undefined, status: 'loading' });
@@ -159,12 +154,25 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
159154

160155
const innerController = new AbortController();
161156
currentInnerController = innerController;
162-
const forwardAbort = () => innerController.abort(outerController.signal.reason);
163-
outerController.signal.addEventListener('abort', forwardAbort, { signal: innerController.signal });
164157
const innerSignal = innerController.signal;
158+
const signal = callerSignal ? AbortSignal.any([innerSignal, callerSignal]) : innerSignal;
159+
// Caller's signal aborting (not just supersede via the inner controller) transitions the
160+
// store to error with the caller's abort reason. Scoped to the inner signal so the
161+
// listener is removed on reconnect / reset.
162+
if (callerSignal) {
163+
callerSignal.addEventListener(
164+
'abort',
165+
() => {
166+
if (innerSignal.aborted) return;
167+
setState({ data: currentState.data, error: callerSignal.reason, status: 'error' });
168+
innerController.abort(callerSignal.reason);
169+
},
170+
{ signal: innerSignal },
171+
);
172+
}
165173

166174
function handleError(err: unknown) {
167-
if (innerSignal.aborted) return;
175+
if (signal.aborted) return;
168176
if (currentState.status === 'error') return;
169177
setState({ data: currentState.data, error: err, status: 'error' });
170178
innerController.abort(err);
@@ -175,9 +183,9 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
175183
}
176184

177185
rpcRequest
178-
.send({ abortSignal: innerSignal })
186+
.send({ abortSignal: signal })
179187
.then(({ context: { slot }, value }) => {
180-
if (innerSignal.aborted) return;
188+
if (signal.aborted) return;
181189
// `lastUpdateSlot` persists across retries so the store never regresses. If the
182190
// retried RPC returns a slot older than one we've already seen, we wait for the
183191
// subscription to deliver something newer before leaving `retrying`.
@@ -188,13 +196,13 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
188196
.catch(handleError);
189197

190198
rpcSubscriptionRequest
191-
.subscribe({ abortSignal: innerSignal })
199+
.subscribe({ abortSignal: signal })
192200
.then(async notifications => {
193201
for await (const {
194202
context: { slot },
195203
value,
196204
} of notifications) {
197-
if (innerSignal.aborted) return;
205+
if (signal.aborted) return;
198206
if (slot < lastUpdateSlot) continue;
199207
lastUpdateSlot = slot;
200208
handleValue({ context: { slot }, value: rpcSubscriptionValueMapper(value) });
@@ -212,7 +220,9 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
212220
}
213221

214222
return {
215-
connect: performConnect,
223+
connect(): void {
224+
performConnect(undefined);
225+
},
216226
getError(): unknown {
217227
return currentState.error;
218228
},
@@ -225,13 +235,20 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
225235
reset: performReset,
226236
retry(): void {
227237
if (currentState.status !== 'error') return;
228-
performConnect();
238+
performConnect(undefined);
229239
},
230240
subscribe(callback: () => void): () => void {
231241
subscribers.add(callback);
232242
return () => {
233243
subscribers.delete(callback);
234244
};
235245
},
246+
withSignal(signal: AbortSignal) {
247+
return {
248+
connect(): void {
249+
performConnect(signal);
250+
},
251+
};
252+
},
236253
};
237254
}

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

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -66,28 +66,26 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
6666
});
6767

6868
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 });
69+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
7270
expect(store.getUnifiedState()).toStrictEqual({
7371
data: undefined,
7472
error: undefined,
7573
status: 'idle',
7674
});
7775
expect(mockTransport).not.toHaveBeenCalled();
7876
});
79-
it('passes the abort signal to the transport on connect()', async () => {
77+
it('passes the per-connection signal to the transport on withSignal().connect()', async () => {
8078
expect.assertions(1);
8179
const abortController = new AbortController();
82-
const store = rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal });
83-
store.connect();
80+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
81+
store.withSignal(abortController.signal).connect();
8482
await flushMicrotasks();
85-
expect(mockTransport).toHaveBeenCalledWith(expect.objectContaining({ signal: abortController.signal }));
83+
const transportSignal = mockTransport.mock.calls[0][0].signal;
84+
// The transport receives a composed signal — aborting the caller's source aborts it.
85+
expect(transportSignal.aborted).toBe(false);
8686
});
8787
it('transitions to `loading` after connect() before the transport resolves', () => {
88-
const store = rpcSubscriptions
89-
.thingNotifications()
90-
.reactiveStore({ abortSignal: new AbortController().signal });
88+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
9189
store.connect();
9290
expect(store.getUnifiedState()).toStrictEqual({
9391
data: undefined,
@@ -97,9 +95,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
9795
});
9896
it('reflects incoming notifications after connect()', async () => {
9997
expect.assertions(1);
100-
const store = rpcSubscriptions
101-
.thingNotifications()
102-
.reactiveStore({ abortSignal: new AbortController().signal });
98+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
10399
store.connect();
104100
await flushMicrotasks();
105101
publish('notification', { value: 42 });
@@ -111,9 +107,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
111107
});
112108
it('calls store subscribers when a notification arrives', async () => {
113109
expect.assertions(1);
114-
const store = rpcSubscriptions
115-
.thingNotifications()
116-
.reactiveStore({ abortSignal: new AbortController().signal });
110+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
117111
store.connect();
118112
await flushMicrotasks();
119113
const subscriber = jest.fn();
@@ -123,9 +117,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
123117
});
124118
it('surfaces errors via getUnifiedState()', async () => {
125119
expect.assertions(1);
126-
const store = rpcSubscriptions
127-
.thingNotifications()
128-
.reactiveStore({ abortSignal: new AbortController().signal });
120+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
129121
store.connect();
130122
await flushMicrotasks();
131123
const error = new Error('o no');
@@ -138,21 +130,19 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
138130
});
139131
it('re-invokes the transport on connect() after an error', async () => {
140132
expect.assertions(1);
141-
const store = rpcSubscriptions
142-
.thingNotifications()
143-
.reactiveStore({ abortSignal: new AbortController().signal });
133+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
144134
store.connect();
145135
await flushMicrotasks();
146136
publish('error', new Error('stream died'));
147137
store.connect();
148138
await flushMicrotasks();
149139
expect(mockTransport).toHaveBeenCalledTimes(2);
150140
});
151-
it('aborts the signal forwarded to the data publisher listeners when the caller aborts', async () => {
141+
it('aborts the signal forwarded to the data publisher listeners when the caller signal aborts', async () => {
152142
expect.assertions(2);
153143
const abortController = new AbortController();
154-
const store = rpcSubscriptions.thingNotifications().reactiveStore({ abortSignal: abortController.signal });
155-
store.connect();
144+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
145+
store.withSignal(abortController.signal).connect();
156146
await flushMicrotasks();
157147
const onCall = mockOn.mock.calls.find(([channel]: [string]) => channel === 'notification');
158148
const listenerSignal = (onCall![2] as { signal: AbortSignal }).signal;
@@ -162,9 +152,7 @@ describe('PendingRpcSubscriptionsRequest.reactiveStore()', () => {
162152
});
163153
it('returns the same getUnifiedState() snapshot across consecutive calls when state has not changed', async () => {
164154
expect.assertions(2);
165-
const store = rpcSubscriptions
166-
.thingNotifications()
167-
.reactiveStore({ abortSignal: new AbortController().signal });
155+
const store = rpcSubscriptions.thingNotifications().reactiveStore();
168156
store.connect();
169157
await flushMicrotasks();
170158
expect(store.getUnifiedState()).toBe(store.getUnifiedState());

packages/rpc-subscriptions-spec/src/rpc-subscriptions-request.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,35 @@ import { ReactiveStreamStore } from '@solana/subscribable';
99
* {@link PendingRpcSubscriptionsRequest | PendingRpcSubscriptionsRequest<TNotification>} will
1010
* trigger the subscription and return a promise for an async iterable that vends `TNotifications`.
1111
*
12-
* Calling the {@link PendingRpcSubscriptionsRequest.reactiveStore | `reactiveStore(options)`}
13-
* method will return a {@link ReactiveStreamStore} compatible with `useSyncExternalStore`, Svelte
12+
* Calling the {@link PendingRpcSubscriptionsRequest.reactiveStore | `reactiveStore()`} method
13+
* will return a {@link ReactiveStreamStore} compatible with `useSyncExternalStore`, Svelte
1414
* stores, and other reactive primitives. The returned store is in `status: 'idle'`; the caller
1515
* is responsible for invoking {@link ReactiveStreamStore.connect | `connect()`} to open the
16-
* underlying stream.
16+
* underlying stream. Attach a caller-provided cancellation source via
17+
* {@link ReactiveStreamStore.withSignal | `withSignal()`}.
1718
*/
1819
export type PendingRpcSubscriptionsRequest<TNotification> = {
1920
/**
2021
* Synchronously returns a {@link ReactiveStreamStore} that holds the latest notification.
2122
* Compatible with `useSyncExternalStore` and other reactive primitives that expect a
2223
* `{ subscribe, getUnifiedState }` contract. The returned store is in `status: 'idle'` — call
2324
* {@link ReactiveStreamStore.connect | `connect()`} to open the subscription; a follow-up
24-
* `connect()` after an error reopens it.
25+
* `connect()` after an error reopens it. Attach a caller-provided cancellation source via
26+
* {@link ReactiveStreamStore.withSignal | `withSignal()`}.
2527
*
2628
* @example
2729
* ```ts
28-
* const store = rpc.accountNotifications(address).reactiveStore({ abortSignal });
29-
* store.connect();
30+
* const store = rpc.accountNotifications(address).reactiveStore();
31+
* // Per-connection timeout — fresh clock per attempt:
32+
* store.withSignal(AbortSignal.timeout(30_000)).connect();
3033
* // React — the unified snapshot has stable identity per update.
3134
* const state = useSyncExternalStore(store.subscribe, store.getUnifiedState);
3235
* if (state.status === 'error') return <ErrorMessage error={state.error} onRetry={store.connect} />;
3336
* if (state.status === 'loading' || state.status === 'idle') return <Spinner />;
3437
* return <View data={state.data} />;
3538
* ```
3639
*/
37-
reactiveStore(options: RpcSubscribeOptions): ReactiveStreamStore<TNotification>;
40+
reactiveStore(): ReactiveStreamStore<TNotification>;
3841
/**
3942
* Triggers the subscription and returns a promise for an async iterable of notifications.
4043
* Use `for await...of` to consume notifications as they arrive. Abort the signal to

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,10 @@ function createPendingRpcSubscription<TNotification>(
8282
subscriptionsPlan: RpcSubscriptionsPlan<TNotification>,
8383
): PendingRpcSubscriptionsRequest<TNotification> {
8484
return {
85-
reactiveStore({ abortSignal }: RpcSubscribeOptions) {
85+
reactiveStore() {
8686
return createReactiveStoreFromDataPublisherFactory<TNotification>({
87-
abortSignal,
88-
createDataPublisher() {
89-
return transport({ signal: abortSignal, ...subscriptionsPlan });
87+
createDataPublisher(signal) {
88+
return transport({ signal, ...subscriptionsPlan });
9089
},
9190
dataChannelName: 'notification',
9291
errorChannelName: 'error',

0 commit comments

Comments
 (0)