Skip to content

Add withSignal() to ReactiveStreamStore for per-connection cancellation#1663

Open
mcintyre94 wants to merge 1 commit into
reactive-store/no-auto-connectfrom
reactive-store/with-signal
Open

Add withSignal() to ReactiveStreamStore for per-connection cancellation#1663
mcintyre94 wants to merge 1 commit into
reactive-store/no-auto-connectfrom
reactive-store/with-signal

Conversation

@mcintyre94

@mcintyre94 mcintyre94 commented May 19, 2026

Copy link
Copy Markdown
Member

Summary of Changes

This PR adds a per-connect abort signal to the reactive stream store. It uses the same pattern as the action store:

// no per-connection signal
store.connect();

// with a signal
store.withSignal(mySignal).connect();

Note that the previous PR removes auto-connect from the stream store, so this same pattern applies to all connects, including the first.

The function to create a data publisher for the store is widened from () => Promise<DataPublisher> to (signal: AbortSignal) => Promise<DataPublisher>, ie the per-connect abort signal can be used as part of that factory.

The abortSignal input to the stream store (and to createReactiveStoreWithInitialValueAndSlotTracking, and PendingRpcSubscriptionsRequest.reactiveStore()) is removed in favour of this per-connect pattern.

mcintyre94 commented May 19, 2026

Copy link
Copy Markdown
Member Author

@bundlemon

bundlemon Bot commented May 19, 2026

Copy link
Copy Markdown

BundleMon

Files updated (22)
Status Path Size Limits
react/dist/index.native.mjs
4.89KB (+1.8KB +58.14%) -
react/dist/index.browser.mjs
4.89KB (+1.8KB +58.06%) -
react/dist/index.node.mjs
4.89KB (+1.8KB +58.07%) -
@solana/kit production bundle
kit/dist/index.production.min.js
52.83KB (+224B +0.42%) -
subscribable/dist/index.browser.mjs
2.78KB (+183B +6.86%) -
subscribable/dist/index.native.mjs
2.79KB (+181B +6.77%) -
subscribable/dist/index.node.mjs
2.86KB (+181B +6.59%) -
errors/dist/index.browser.mjs
20.9KB (+179B +0.84%) -
errors/dist/index.native.mjs
20.9KB (+179B +0.84%) -
errors/dist/index.node.mjs
20.92KB (+179B +0.84%) -
kit/dist/index.node.mjs
4.64KB (+174B +3.8%) -
kit/dist/index.browser.mjs
4.64KB (+173B +3.78%) -
kit/dist/index.native.mjs
4.64KB (+173B +3.78%) -
wallet-account-signer/dist/index.browser.mjs
17.69KB (+169B +0.94%) -
wallet-account-signer/dist/index.native.mjs
17.69KB (+168B +0.94%) -
wallet-account-signer/dist/index.node.mjs
17.71KB (+168B +0.93%) -
rpc-subscriptions-spec/dist/index.browser.mjs
2.19KB (-13B -0.58%) -
rpc-subscriptions-spec/dist/index.native.mjs
2.19KB (-13B -0.58%) -
rpc-subscriptions-spec/dist/index.node.mjs
2.23KB (-13B -0.56%) -
rpc-spec/dist/index.browser.mjs
898B (-20B -2.18%) -
rpc-spec/dist/index.native.mjs
897B (-21B -2.29%) -
rpc-spec/dist/index.node.mjs
896B (-21B -2.29%) -
Unchanged files (125)
Status Path Size Limits
rpc-graphql/dist/index.browser.mjs
18.82KB -
rpc-graphql/dist/index.native.mjs
18.81KB -
rpc-graphql/dist/index.node.mjs
18.81KB -
transaction-messages/dist/index.browser.mjs
11.32KB -
transaction-messages/dist/index.native.mjs
11.32KB -
transaction-messages/dist/index.node.mjs
11.32KB -
instruction-plans/dist/index.browser.mjs
6.58KB -
instruction-plans/dist/index.native.mjs
6.58KB -
instruction-plans/dist/index.node.mjs
6.58KB -
fixed-points/dist/index.browser.mjs
5.08KB -
fixed-points/dist/index.native.mjs
5.07KB -
fixed-points/dist/index.node.mjs
5.07KB -
codecs-data-structures/dist/index.browser.mjs
5.04KB -
codecs-data-structures/dist/index.native.mjs
5.03KB -
codecs-data-structures/dist/index.node.mjs
5.03KB -
offchain-messages/dist/index.browser.mjs
4.89KB -
offchain-messages/dist/index.native.mjs
4.89KB -
offchain-messages/dist/index.node.mjs
4.89KB -
transactions/dist/index.browser.mjs
4.07KB -
transactions/dist/index.native.mjs
4.07KB -
transactions/dist/index.node.mjs
4.07KB -
codecs-core/dist/index.browser.mjs
3.62KB -
codecs-core/dist/index.native.mjs
3.62KB -
codecs-core/dist/index.node.mjs
3.62KB -
webcrypto-ed25519-polyfill/dist/index.node.mj
s
3.61KB -
webcrypto-ed25519-polyfill/dist/index.browser
.mjs
3.59KB -
webcrypto-ed25519-polyfill/dist/index.native.
mjs
3.57KB -
rpc-subscriptions/dist/index.browser.mjs
3.37KB -
rpc-subscriptions/dist/index.node.mjs
3.34KB -
rpc-subscriptions/dist/index.native.mjs
3.31KB -
signers/dist/index.browser.mjs
3.26KB -
signers/dist/index.native.mjs
3.26KB -
signers/dist/index.node.mjs
3.26KB -
rpc-transformers/dist/index.browser.mjs
3.16KB -
rpc-transformers/dist/index.native.mjs
3.16KB -
rpc-transformers/dist/index.node.mjs
3.16KB -
keys/dist/index.node.mjs
3.06KB -
addresses/dist/index.browser.mjs
2.93KB -
addresses/dist/index.native.mjs
2.92KB -
addresses/dist/index.node.mjs
2.92KB -
keys/dist/index.browser.mjs
2.85KB -
keys/dist/index.native.mjs
2.85KB -
codecs-strings/dist/index.browser.mjs
2.55KB -
codecs-strings/dist/index.node.mjs
2.51KB -
codecs-strings/dist/index.native.mjs
2.47KB -
transaction-confirmation/dist/index.node.mjs
2.42KB -
transaction-confirmation/dist/index.native.mj
s
2.37KB -
sysvars/dist/index.browser.mjs
2.37KB -
sysvars/dist/index.native.mjs
2.37KB -
transaction-confirmation/dist/index.browser.m
js
2.37KB -
sysvars/dist/index.node.mjs
2.37KB -
rpc/dist/index.node.mjs
1.95KB -
codecs-numbers/dist/index.browser.mjs
1.95KB -
codecs-numbers/dist/index.native.mjs
1.95KB -
codecs-numbers/dist/index.node.mjs
1.94KB -
rpc-transport-http/dist/index.browser.mjs
1.89KB -
rpc-transport-http/dist/index.native.mjs
1.89KB -
rpc/dist/index.native.mjs
1.81KB -
rpc-types/dist/index.browser.mjs
1.8KB -
rpc/dist/index.browser.mjs
1.8KB -
rpc-types/dist/index.native.mjs
1.8KB -
rpc-types/dist/index.node.mjs
1.8KB -
rpc-transport-http/dist/index.node.mjs
1.71KB -
rpc-subscriptions-channel-websocket/dist/inde
x.node.mjs
1.33KB -
rpc-subscriptions-channel-websocket/dist/inde
x.native.mjs
1.27KB -
rpc-subscriptions-channel-websocket/dist/inde
x.browser.mjs
1.26KB -
program-client-core/dist/index.browser.mjs
1.21KB -
program-client-core/dist/index.native.mjs
1.21KB -
program-client-core/dist/index.node.mjs
1.21KB -
options/dist/index.browser.mjs
1.18KB -
options/dist/index.native.mjs
1.18KB -
options/dist/index.node.mjs
1.17KB -
accounts/dist/index.browser.mjs
1.17KB -
accounts/dist/index.native.mjs
1.17KB -
accounts/dist/index.node.mjs
1.16KB -
rpc-api/dist/index.browser.mjs
998B -
rpc-api/dist/index.native.mjs
997B -
rpc-api/dist/index.node.mjs
995B -
compat/dist/index.browser.mjs
969B -
compat/dist/index.native.mjs
968B -
compat/dist/index.node.mjs
966B -
rpc-spec-types/dist/index.browser.mjs
962B -
rpc-spec-types/dist/index.native.mjs
961B -
rpc-spec-types/dist/index.node.mjs
959B -
rpc-subscriptions-api/dist/index.native.mjs
871B -
rpc-subscriptions-api/dist/index.browser.mjs
870B -
rpc-subscriptions-api/dist/index.node.mjs
870B -
promises/dist/index.native.mjs
841B -
promises/dist/index.node.mjs
840B -
promises/dist/index.browser.mjs
839B -
plugin-core/dist/index.browser.mjs
820B -
plugin-core/dist/index.native.mjs
819B -
plugin-core/dist/index.node.mjs
817B -
assertions/dist/index.browser.mjs
783B -
instructions/dist/index.browser.mjs
771B -
instructions/dist/index.native.mjs
770B -
instructions/dist/index.node.mjs
768B -
fast-stable-stringify/dist/index.browser.mjs
726B -
fast-stable-stringify/dist/index.native.mjs
725B -
assertions/dist/index.native.mjs
724B -
fast-stable-stringify/dist/index.node.mjs
724B -
assertions/dist/index.node.mjs
723B -
programs/dist/index.browser.mjs
329B -
programs/dist/index.native.mjs
327B -
programs/dist/index.node.mjs
325B -
fs-impl/dist/index.browser.mjs
245B -
event-target-impl/dist/index.node.mjs
230B -
functional/dist/index.browser.mjs
154B -
functional/dist/index.native.mjs
152B -
text-encoding-impl/dist/index.native.mjs
152B -
functional/dist/index.node.mjs
151B -
codecs/dist/index.browser.mjs
145B -
codecs/dist/index.native.mjs
144B -
codecs/dist/index.node.mjs
142B -
event-target-impl/dist/index.browser.mjs
133B -
ws-impl/dist/index.node.mjs
131B -
text-encoding-impl/dist/index.browser.mjs
122B -
fs-impl/dist/index.node.mjs
120B -
text-encoding-impl/dist/index.node.mjs
119B -
ws-impl/dist/index.browser.mjs
113B -
crypto-impl/dist/index.node.mjs
111B -
crypto-impl/dist/index.browser.mjs
109B -
rpc-parsed-types/dist/index.browser.mjs
66B -
rpc-parsed-types/dist/index.native.mjs
65B -
rpc-parsed-types/dist/index.node.mjs
63B -

Total files change +7.57KB +1.43%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Documentation Preview: https://kit-docs-ihyp5wbuh-anza-tech.vercel.app

@mcintyre94 mcintyre94 added the major This would require a major version bump label May 19, 2026
@mcintyre94

Copy link
Copy Markdown
Member Author

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Replaces the construction-time abortSignal option on createReactiveStoreFromDataPublisherFactory, createReactiveStoreWithInitialValueAndSlotTracking, and PendingRpcSubscriptionsRequest.reactiveStore() with a per-connection store.withSignal(signal).connect() pattern, mirroring the action store. The createDataPublisher factory is widened from () => Promise<DataPublisher> to (signal: AbortSignal) => Promise<DataPublisher> so the underlying transport can stop on per-connection abort, not just listener cleanup.

The design is sound and the docs/changeset are thorough. Semantics are well thought out:

  • Caller-signal abort → transitions to error with the abort reason (visible).
  • Inner-controller abort (supersession via a fresh connect() or reset()) → silent so the newer call owns state.
  • Already-aborted caller signal at connect() time short-circuits to error without invoking the factory.
  • The caller-abort listener is scoped to { signal: innerSignal } so it auto-removes on supersede/reset — no listener accumulation across reconnects.
  • The error-channel handler still early-returns if currentState.status === 'error', so a publisher error arriving after a caller abort can't overwrite the abort reason.

This looks good to me. A few notes below — nothing blocking.

Implementation divergence between the two stores

The two performConnect implementations have diverged in how they propagate the caller signal:

  • packages/subscribable/src/reactive-stream-store.ts composes AbortSignal.any([innerController.signal, callerSignal]) and passes that composed signal to both createDataPublisher(signal) and the { signal } option on every publisher.on(...).
  • packages/kit/src/create-reactive-store-with-initial-value-and-slot-tracking.ts passes only innerSignal to rpcRequest.send / rpcSubscriptionRequest.subscribe, and relies on the caller-abort listener calling innerController.abort(callerSignal.reason) to propagate cancellation.

Functionally equivalent today — in both, the RPC/transport eventually sees an aborted signal carrying the caller's reason. But two slightly different shapes for the same pattern in sibling files is the kind of drift that bites later (e.g. if AbortSignal.any is ever load-bearing for one but not the other). Worth a follow-up to converge on one pattern, probably the AbortSignal.any one in the subscribable store, since it surfaces the caller reason to the transport without depending on the listener firing first.

Small coverage regression in the kit test

In packages/kit/src/__tests__/create-reactive-store-with-initial-value-and-slot-tracking-test.ts, the previous versions of the first two tests in the new withSignal() describe block asserted expect(rpcSignal.reason).toBe('test reason') / expect(subscriptionSignal.reason).toBe('test reason') — pinning that the caller's abort reason propagates all the way through to the RPC and subscription abortSignal. The new versions only check .aborted === true and drop the .reason assertions. Given the design explicitly forwards the reason (innerController.abort(callerSignal.reason) and setState({ error: callerSignal.reason, ... })), it'd be worth keeping those reason assertions — the 'does not overwrite the abort-reason error with a late RPC rejection' test only verifies the store-level error, not the signal-level reason that downstream transports actually observe.

For subsequent reviewers

  • retry() and bare connect() after a caller-abort error drop the bound signal. This is intentional and called out in the withSignal JSDoc ("Bare store.connect() calls bypass the bound signal — discipline required"). Worth confirming this is the desired UX, especially for the React <ErrorMessage onRetry={store.connect} /> example shown in the docs — if a caller attached a kill switch via withSignal, that retry button bypasses it. May want a follow-up that returns a retry() on the withSignal wrapper too, for symmetry.
  • The createDataPublisher widening is source-compatible (TS allows fewer parameters than declared) but the PR is still correctly marked major because the construction-time abortSignal option is removed.
  • Changeset is present, well-scoped to the three affected packages, and the bumps + prose match the repo's conventions. Skill instructions look respected (auto-generated filename, no manual edits).

@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-connect branch from 3bccb76 to 562ec8f Compare May 19, 2026 14:43
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from 28fb867 to bed0452 Compare May 19, 2026 14:43
@changeset-bot

changeset-bot Bot commented May 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 51febd9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 47 packages
Name Type
@solana/subscribable Major
@solana/rpc-subscriptions-spec Major
@solana/kit Major
@solana/react Major
@solana/rpc-spec Major
@solana/rpc-subscriptions-channel-websocket Major
@solana/rpc-subscriptions Major
@solana/plugin-interfaces Major
@solana/rpc-subscriptions-api Major
@solana/accounts Major
@solana/rpc-api Major
@solana/rpc-transport-http Major
@solana/rpc Major
@solana/sysvars Major
@solana/transaction-confirmation Major
@solana/program-client-core Major
@solana/rpc-graphql Major
@solana/addresses Major
@solana/assertions Major
@solana/codecs-core Major
@solana/codecs-data-structures Major
@solana/codecs-numbers Major
@solana/codecs-strings Major
@solana/codecs Major
@solana/compat Major
@solana/errors Major
@solana/fast-stable-stringify Major
@solana/fixed-points Major
@solana/functional Major
@solana/instruction-plans Major
@solana/instructions Major
@solana/keys Major
@solana/nominal-types Major
@solana/offchain-messages Major
@solana/options Major
@solana/plugin-core Major
@solana/programs Major
@solana/promises Major
@solana/rpc-parsed-types Major
@solana/rpc-spec-types Major
@solana/rpc-transformers Major
@solana/rpc-types Major
@solana/signers Major
@solana/transaction-messages Major
@solana/transactions Major
@solana/wallet-account-signer Major
@solana/webcrypto-ed25519-polyfill Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from bed0452 to b631bce Compare May 19, 2026 15:15
@mcintyre94

Copy link
Copy Markdown
Member Author

Implementation divergence between the two stores

Agreed, aligned on the AbortSignal.any from subscribable

Small coverage regression in the kit test

Updated these tests to check reason again

retry() and bare connect() after a caller-abort error drop the bound signal.

Intentional, but tweaked the docstring a bit

@lorisleiva lorisleiva left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store.withMyApproval().connect() 🫡

@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-connect branch from 9cad348 to 176febd Compare May 29, 2026 10:36
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from 1b2b3f3 to 0c67325 Compare May 29, 2026 10:36
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from 0c67325 to ade3616 Compare June 3, 2026 15:17
@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-connect branch from 345a3d2 to 63ce806 Compare June 5, 2026 15:39
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from ade3616 to 380b4f8 Compare June 5, 2026 15:39
@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-connect branch from 63ce806 to b620102 Compare June 5, 2026 15:47
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch 2 times, most recently from 1ec234a to 7615ebb Compare June 5, 2026 15:56
@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-connect branch 2 times, most recently from 645eff6 to 54df544 Compare June 10, 2026 15:43
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from 7615ebb to 34b65f1 Compare June 10, 2026 15:43
@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-connect branch from 54df544 to 700d89a Compare June 10, 2026 16:49
@mcintyre94 mcintyre94 force-pushed the reactive-store/with-signal branch from 34b65f1 to 4197ebe Compare June 10, 2026 16:49
@mcintyre94 mcintyre94 added the do-not-close Add this tag to exempt an issue/PR from being closed by the stalebot label Jun 11, 2026
…lation

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-close Add this tag to exempt an issue/PR from being closed by the stalebot major This would require a major version bump

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants