Skip to content

Add withSignal to reactive action store for per-dispatch cancellation#1614

Open
mcintyre94 wants to merge 1 commit into
reactive-store/no-auto-dispatchfrom
reactive-store/abort-signal
Open

Add withSignal to reactive action store for per-dispatch cancellation#1614
mcintyre94 wants to merge 1 commit into
reactive-store/no-auto-dispatchfrom
reactive-store/abort-signal

Conversation

@mcintyre94
Copy link
Copy Markdown
Member

@mcintyre94 mcintyre94 commented May 11, 2026

Summary of Changes

Previously I didn't add an abort signal to the ReactiveActionStore, but I've realised that as this can be used with egPendingRpcRequest it is useful for consumers to be able to control the abort logic - eg to pass AbortSignal.timeout(N).

I think it makes sense for this signal to be per dispatch, ie not a single abort signal passed into the store on create.

This PR adds an optional API: store.withSignal(signal).dispatch(...) to call dispatch or dispatchAsync with an additional abort signal. It's combined with the store controller signal using AbortSignal.any.

store.dispatch(...) and store.dispatchAsync(...) remain unchanged.

The same per-request logic also applies for the ReactiveStreamStore, this will be addressed in a later PR.

Note that we use AbortSignal.any for combining signals. This required Claude to polyfill it for jsdom because Jest, but it's widely supported and is in Baseline 2024. Much better than the signal juggling we have in the stream store (which will be refactored in the upstack PR too)

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: 4d72b50

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 Minor
@solana/kit Minor
@solana/react Minor
@solana/rpc-spec Minor
@solana/rpc-subscriptions-channel-websocket Minor
@solana/rpc-subscriptions-spec Minor
@solana/rpc-subscriptions Minor
@solana/accounts Minor
@solana/plugin-interfaces Minor
@solana/rpc-api Minor
@solana/rpc-transport-http Minor
@solana/rpc Minor
@solana/sysvars Minor
@solana/rpc-subscriptions-api Minor
@solana/transaction-confirmation Minor
@solana/program-client-core Minor
@solana/rpc-graphql Minor
@solana/addresses Minor
@solana/assertions Minor
@solana/codecs-core Minor
@solana/codecs-data-structures Minor
@solana/codecs-numbers Minor
@solana/codecs-strings Minor
@solana/codecs Minor
@solana/compat Minor
@solana/errors Minor
@solana/fast-stable-stringify Minor
@solana/fixed-points Minor
@solana/functional Minor
@solana/instruction-plans Minor
@solana/instructions Minor
@solana/keys Minor
@solana/nominal-types Minor
@solana/offchain-messages Minor
@solana/options Minor
@solana/plugin-core Minor
@solana/programs Minor
@solana/promises Minor
@solana/rpc-parsed-types Minor
@solana/rpc-spec-types Minor
@solana/rpc-transformers Minor
@solana/rpc-types Minor
@solana/signers Minor
@solana/transaction-messages Minor
@solana/transactions Minor
@solana/wallet-account-signer Minor
@solana/webcrypto-ed25519-polyfill Minor

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

Copy link
Copy Markdown
Member Author

mcintyre94 commented May 11, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@bundlemon
Copy link
Copy Markdown

bundlemon Bot commented May 11, 2026

BundleMon

Files updated (16)
Status Path Size Limits
react/dist/index.native.mjs
3.89KB (+815B +25.73%) -
react/dist/index.browser.mjs
3.89KB (+814B +25.69%) -
react/dist/index.node.mjs
3.89KB (+814B +25.71%) -
errors/dist/index.browser.mjs
20.69KB (+170B +0.81%) -
errors/dist/index.native.mjs
20.69KB (+170B +0.81%) -
errors/dist/index.node.mjs
20.71KB (+170B +0.81%) -
wallet-account-signer/dist/index.native.mjs
17.53KB (+165B +0.93%) -
wallet-account-signer/dist/index.browser.mjs
17.53KB (+164B +0.92%) -
wallet-account-signer/dist/index.node.mjs
17.54KB (+164B +0.92%) -
subscribable/dist/index.node.mjs
2.78KB (+105B +3.83%) -
subscribable/dist/index.browser.mjs
2.71KB (+103B +3.86%) -
subscribable/dist/index.native.mjs
2.71KB (+103B +3.85%) -
@solana/kit production bundle
kit/dist/index.production.min.js
52.34KB (+74B +0.14%) -
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 (131)
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 -
kit/dist/index.browser.mjs
3.97KB -
kit/dist/index.native.mjs
3.97KB -
kit/dist/index.node.mjs
3.97KB -
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-subscriptions-spec/dist/index.node.mjs
2.25KB -
rpc-subscriptions-spec/dist/index.native.mjs
2.2KB -
rpc-subscriptions-spec/dist/index.browser.mjs
2.2KB -
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.91KB -
rpc-transport-http/dist/index.native.mjs
1.9KB -
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.72KB -
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
870B -
rpc-subscriptions-api/dist/index.node.mjs
869B -
rpc-subscriptions-api/dist/index.browser.mjs
868B -
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 +3.68KB +0.7%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

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

Summary

Adds perRequestSignal / perConnectionSignal factory options to the reactive stores so callers can compose a fresh signal per dispatch / per connection (e.g. () => AbortSignal.timeout(N)) while still expressing a kill switch by returning the same cancellable signal each call. Internally rewires reactive-stream-store.ts to use AbortSignal.any for signal composition (replacing the manual outer/inner forwarder), preserves the existing abortSignal option with @deprecated, and polyfills AbortSignal.any in the shared jsdom test environment. Changeset is minor for both packages.

The overall shape is good — the factory API is a clean generalization over a single signal, and the action/stream stores end up with parallel mental models. The internal refactor of connect() is genuinely simpler than the previous outerController + forwardAbort dance.

Key things to look at

  • Possible test bug in reactive-action-store-test.ts'lets later dispatches recover when the factory returns a fresh signal each call'. The first dispatch supplies an already-aborted perRequestSignal, but fn = () => Promise.resolve('ok') doesn't read the signal and resolves successfully. The post-await check is if (controller.signal.aborted) — only the per-dispatch controller, not the combined signal — so the dispatch falls into setState({ status: 'success' }). I'd expect the assertion expect(store.getState().status).toBe('error') to fail. Either the test needs an fn that actually rejects on signal.aborted, or the implementation should also surface aborts on the combined signal. Worth running this one in isolation to confirm. (Left an inline comment.)
  • Asymmetry between perRequestSignal and perConnectionSignal return types. Action-store factory returns AbortSignal | undefined; stream-store factory returns AbortSignal. Both are defensible but it's inconsistent in the public surface — easy to unify on AbortSignal | undefined for both, with the stream-store's connect() already handling the undefined branch via if (perConnection) signals.push(perConnection).
  • PendingRpcSubscriptionsRequest.reactiveStore isn't migrated. The wiring in packages/rpc-subscriptions-spec/src/rpc-subscriptions.ts still passes { abortSignal } through to createReactiveStoreFromDataPublisherFactory. Backwards-compatible, but the symmetric ergonomic win for subscription consumers (per-connection timeouts on RPC subs) isn't actually exposed end-to-end here — only the action-side PendingRpcRequest.reactiveStore got the new option. Intentional follow-up?
  • @deprecated abortSignal is still load-bearing inside the repo. The deprecation is correct but worth being aware that the only first-party caller (rpc-subscriptions-spec) still uses it, so the deprecation warning will fire in our own builds the moment anyone enables @typescript-eslint/no-deprecated or similar.

Notes for subsequent reviewers

  • The behavioral change in the action-store catch block is subtle and worth a second look: previously if (signal.aborted) throw signal.reason covered both supersession and any external abort because signal was just controller.signal. Now signal is the combined signal, so the post-await check was correctly narrowed to controller.signal.aborted only — that's how perRequestSignal aborts get surfaced as error state rather than silently dropped. The comments document this well, just confirm the intent matches your mental model.
  • The FactoryConfig discriminated union ({ abortSignal; perConnectionSignal? } | { abortSignal?; perConnectionSignal }) requires at least one of the two to be present at the type level, which is what we want. {} correctly fails to typecheck. Good.
  • The jsdom AbortSignal.any polyfill is scoped to a single static-method patch on jsdom's existing class — this is the right call (cross-realm replacement would break jsdom's brand checks on addEventListener({ signal }), as the comment notes). The implementation looks correct, including the early-abort short-circuit and { once: true, signal: controller.signal } cleanup so input listeners go away once the combined signal aborts.
  • AbortSignal.any cleanup semantics: confirmed the signals array passed into AbortSignal.any doesn't retain listener references after the combined signal aborts — so retries don't leak listeners onto a long-lived longLivedAbortSignal across many retry() calls. That was the main concern with the old forwardAbort approach and it's resolved here.

Comment on lines +233 to +247
it('lets later dispatches recover when the factory returns a fresh signal each call', async () => {
expect.assertions(2);
const signals = [AbortSignal.abort(new Error('first')), new AbortController().signal];
const store = createReactiveActionStore(() => Promise.resolve('ok'), {
perRequestSignal: () => signals.shift(),
});
await store.dispatchAsync().catch(() => {});
expect(store.getState().status).toBe('error');
await store.dispatchAsync();
expect(store.getState()).toStrictEqual({
data: 'ok',
error: undefined,
status: 'success',
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think this test as written should fail. Tracing through:

  1. First dispatch: signals.shift() returns AbortSignal.abort(new Error('first')) — already aborted.
  2. AbortSignal.any([controller.signal, abortedSignal]) returns an already-aborted combined signal.
  3. fn = () => Promise.resolve('ok') — never reads signal, just resolves to 'ok'.
  4. Post-await check is if (controller.signal.aborted)controller was just created and hasn't been aborted, so this is false.
  5. Falls through to setState({ ..., status: 'success' }).

So the first expect(...).toBe('error') should be 'success'. Either:

  • the fn needs to actually honor the signal (e.g. (signal) => signal.aborted ? Promise.reject(signal.reason) : Promise.resolve('ok')), making this a real test of the recovery path, or
  • the implementation should also check the combined signal.aborted post-await and surface that as an error (which would be a behavior change — currently only the per-dispatch controller can produce that throw).

Could you double-check this one runs green locally? If it does I'm missing something.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@trevor-cortex check the updated implementation of dispatchAsync in createReactiveActionStore:

const dispatchAsync = async (...args: TArgs): Promise<TResult> => {
currentController?.abort();
const controller = new AbortController();
currentController = controller;
const perRequest = perRequestSignal?.();
const signal = perRequest ? AbortSignal.any([controller.signal, perRequest]) : controller.signal;
const previousData = state.data;
setState({ data: previousData, error: undefined, status: 'running' });
try {
const result = await getAbortablePromise(fn(signal, ...args), signal);
if (signal.aborted) {
throw signal.reason;
}
setState({ data: result, error: undefined, status: 'success' });
return result;
} catch (error) {
// Superseded by a newer dispatch or `reset()` — drop silently so only the most recent
// dispatch mutates state, and reject with the abort reason rather than any underlying
// failure that happened to race the abort.
if (controller.signal.aborted) {
throw controller.signal.reason;
}
// Real failure or `perRequestSignal` firing — surface as error state.
setState({ data: previousData, error, status: 'error' });
throw error;
}
};

To clarify, the test does pass in CI and locally

Here's what I think is happening:

  • Signal is set to an aborted signal because perRequest is aborted: const signal = perRequest ? AbortSignal.any([controller.signal, perRequest]) : controller.signal;
  • The abortable promise with signal throws: const result = await getAbortablePromise(fn(signal, ...args), signal);
  • In the catch block, we haven't aborted the controller (just per-request), so: if (controller.signal.aborted) is false
  • We fall through to setting the error:
// Real failure or `perRequestSignal` firing — surface as error state.
setState({ data: previousData, error, status: 'error' });
throw error;

Can you double check I've got all this right?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right, my mistake — I missed that fn(signal) is wrapped in getAbortablePromise(..., signal), which is what makes the already-aborted combined signal reject even though fn itself ignores it. Tracing again:

  1. signals.shift() → already-aborted AbortSignal.abort(new Error('first')).
  2. signal = AbortSignal.any([controller.signal, perRequest]) is already aborted.
  3. getAbortablePromise(fn(signal), signal) rejects with signal.reason because signal.aborted is true — fn resolving to 'ok' doesn't matter, the race is decided by the signal.
  4. Catch block: controller.signal.aborted is false (only perRequest aborted), so we fall through to setState({ status: 'error' }). ✅

Second dispatch then gets a fresh non-aborted signal and succeeds. So the test is correct and exercises exactly the recovery path it claims to. Sorry for the noise — that was the piece I was missing.

Comment thread packages/subscribable/src/reactive-action-store.ts Outdated
Comment thread packages/subscribable/src/reactive-stream-store.ts Outdated
Comment on lines +333 to +336
function connect() {
if (outerController.signal.aborted) return;
// Inner signal is passed to data publisher
if (longLivedAbortSignal?.aborted) return;
// Factory called per connection — fresh signal per attempt for `perConnectionSignal`,
// same kill-able signal returned on every call for the kill-switch pattern.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sanity-checking the deprecated abortSignal path under the new wiring: previously, aborting the caller's abortSignal propagated through outerControllerforwardAbortinnerController, which removed the publisher listeners (since they were { signal: innerController.signal }). Now longLivedAbortSignal is one of the inputs to AbortSignal.any, so the combined signal aborts when the caller aborts, and the publisher listeners (registered with { signal }) are removed the same way. ✓

One difference worth confirming: in the old code, aborting the caller's abortSignal would also propagate to innerController directly, so any code reading innerController.signal.aborted would see true. The new code reads the combined signal everywhere instead, which is correct — but if any future code path branches on innerController.signal.aborted specifically (e.g. "was this aborted by the error channel, vs by the caller?"), that signal stays false on caller-driven aborts now. Not a problem today, just a heads-up for whoever maintains this next.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is intentional, they're now just treated as independent signals

Comment on lines +24 to +39
JsdomAbortSignal.any = function any(signals: readonly AbortSignal[]): AbortSignal {
const controller = new JsdomAbortController();
const alreadyAborted = signals.find(s => s.aborted);
if (alreadyAborted) {
controller.abort(alreadyAborted.reason);
return controller.signal;
}
for (const inputSignal of signals) {
inputSignal.addEventListener(
'abort',
function () {
if (!controller.signal.aborted) controller.abort(this.reason);
},
{ once: true, signal: controller.signal },
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Polyfill looks correct. Two small things:

  1. Inside the listener, this.reason relies on this being bound to the input signal that fired the event. That works because addEventListener invokes listeners with this === currentTarget, but it's the kind of thing TypeScript-strict reviewers tend to flag. event.currentTarget (or capturing the input signal in the closure) is slightly more explicit. Style call.
  2. JsdomAbortController is typed as typeof globalThis.AbortController — fine for the polyfill, but note that the controller.signal returned here is a jsdom AbortSignal. That's correct (we want it to pass jsdom's brand checks), just worth a mental note if anyone debugs realm issues later.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 11, 2026

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

@mcintyre94
Copy link
Copy Markdown
Member Author

This PR has been simplified to only add perRequestSignal to the ReactiveActionStore. Changes related to ReactiveStreamStore are moved to an upstack PR (not submitted yet)

@mcintyre94 mcintyre94 changed the title Add perRequestSignal / perConnectionSignal factory options to reactive stores Add perRequestSignal factory option to reactive action store May 11, 2026
@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

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

LGTM (commenting since I can't approve). Scope tightened nicely — stream-store changes deferred to the follow-up, so this PR is now just the action-store perRequestSignal factory plus the wiring through PendingRpcRequest.reactiveStore and the jsdom AbortSignal.any polyfill. The factory shape is a clean generalization: per-attempt timeouts via () => AbortSignal.timeout(N) and kill-switch semantics via () => killCtrl.signal both fall out of the same primitive.

From my prior review, the three substantive points are resolved/scoped out:

  • The test I flagged ('lets later dispatches recover when the factory returns a fresh signal each call') is correct — getAbortablePromise(fn(signal), signal) races against the combined signal, so an already-aborted perRequestSignal rejects the await even when fn itself doesn't read the signal. Thanks for the walkthrough.
  • The perRequestSignal / perConnectionSignal return-type asymmetry is moot now that perConnectionSignal isn't in this PR.
  • PendingRpcSubscriptionsRequest.reactiveStore not being migrated is explicitly deferred to the upstack stream-store PR.

Notes for subsequent reviewers

  • The narrowing in the catch block from signal.aborted to controller.signal.aborted is the load-bearing behavior change: previously signal was just the per-dispatch controller, so it covered supersession only; now signal is the combined signal, and the post-await check is correctly narrowed to controller.signal so that perRequestSignal aborts surface as error state rather than being silently dropped as supersession. The inline comments document this well.
  • The jsdom AbortSignal.any polyfill is scoped to a single static-method patch on jsdom's existing class (cross-realm replacement would break jsdom's brand checks for addEventListener({ signal }), as the comment notes). Implementation is correct: early-abort short-circuit, and { once: true, signal: controller.signal } so input-signal listeners are cleaned up once the combined signal aborts — no listener leak on long-lived perRequestSignals across many dispatches.
  • Changeset is minor for both @solana/subscribable and @solana/rpc-spec; both are pre-1.0 so reviewers should treat that as breaking-equivalent per the repo's convention.

@mcintyre94
Copy link
Copy Markdown
Member Author

Changeset is minor for both @solana/subscribable and @solana/rpc-spec; both are pre-1.0 so reviewers should treat that as breaking-equivalent per the repo's convention.

I think the line about pre-1.0 in our claude.md is confusing trevor, I've seen this locally too. But to clarify, minor because non breaking!

@mcintyre94 mcintyre94 marked this pull request as ready for review May 11, 2026 17:59
@mcintyre94 mcintyre94 requested review from lorisleiva May 11, 2026 17:59
Copy link
Copy Markdown
Member

@lorisleiva lorisleiva left a comment

Choose a reason for hiding this comment

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

Hmm I'm not a massive fan of how decoupled the AbortSignal is from the actual dispatch call. It feels like a significant indirection when ideally you'd want dispatch(...args, { signal }). Could you tell me what we'd be missing if we made each dispatchable function responsible for their own AbortSignal? That is, they must add them explicitly to their function signature.

@mcintyre94 mcintyre94 changed the base branch from react/use-action to graphite-base/1614 May 12, 2026 12:58
@mcintyre94
Copy link
Copy Markdown
Member Author

@lorisleiva Agreed, thanks for the suggestion! This design is similar to what we have in the stream store ATM, which is an abortSignal passed in the factory config. My plan (in the later PR updating that) was to deprecate that and introduce the perConnectionSignal alongside it, which led to doing the same with perRequestSignal here.

But I think it's better to add signal as an optional options object on the calls to dispatch and dispatchAsync. We'd still internally combine that with a signal that is aborted when reset is called, but I agree it's more direct and clear. It also enables some interesting use cases, like different timeouts per dispatch, a per-call cancellation, and more composability.

The only downside is that if you do want the same kill-switch signal, or the same signal factory, then you need to repeat it on every dispatch. But it's definitely more obvious what's going on!

Will update this.

@mcintyre94 mcintyre94 force-pushed the reactive-store/abort-signal branch from b04eae2 to 1c2b3e9 Compare May 12, 2026 17:52
@mcintyre94 mcintyre94 force-pushed the graphite-base/1614 branch from 6f0326e to 000d8bb Compare May 12, 2026 17:52
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1614 to react/use-action May 12, 2026 17:52
@mcintyre94
Copy link
Copy Markdown
Member Author

I went with the fluent helper idea to cleanly separate the signal from the args: store.withSignal(AbortSignal.timeout(5_000).dispatch(...). When this is passed we use AbortSignal.any to abort if either this signal, or the store controller signal (aborted on eg reset()) is aborted.

This is non-breaking, store.dispatch(...) dispatches without an additional signal as previously, and only aborts if the store controller signal is aborted.

The .reactiveStore() on PendingRpcRequest now takes an optional { signal }: const store = rpc.getLatestBlockhash().reactiveStore({ signal: AbortSignal.timeout(5000) }). Subsequent calls do not use the same signal, they use the same store.withSignal(newSignal).dispatch(...) mechanism. I think this is potentially a little confusing, but on balance it's the best API. Using the same signal would be confusing, and re-introducing the factory concept here would have the same concerns about the signal being implicit and restrict the API.

@mcintyre94 mcintyre94 requested a review from trevor-cortex May 12, 2026 18:01
@trevor-cortex
Copy link
Copy Markdown

Worth noting the API has already moved in this direction since the PR description was written — the current head no longer exposes a perRequestSignal factory at the store level. It's now:

  • createReactiveActionStore(fn)fn receives the per-dispatch AbortSignal as its first positional arg (always plumbed through the internal controller).
  • store.dispatch(...args) / store.dispatchAsync(...args) — bare dispatch, no caller signal.
  • store.withSignal(signal).dispatch(...args) — per-call composition with the internal controller via AbortSignal.any.
  • source.reactiveStore({ signal }) — one-shot signal for the implicit initial dispatch that reactiveStore() fires on construction.

So modulo shape, this is pretty close to dispatch(...args, { signal }) — the question is really about the withSignal(signal).dispatch(...) indirection vs. a trailing options bag. Callum will have the authoritative answer, but my read on the trade-offs:

For withSignal over dispatch(...args, { signal }):

  • dispatch is variadic on TArgs extends readonly unknown[]. A trailing options object would either need runtime detection ("is the last arg an options bag?") or change the shape to dispatch(args, options?), which loses the nice 1:1 with fn's tail args. withSignal sidesteps that entirely.
  • withSignal doubles as a binding primitive — const killable = store.withSignal(killCtrl.signal) lets you bind once and reuse the kill switch from many call sites without each one having to know about it. Per-call { signal } forces every site to thread it.
  • The wrapped fn already takes signal as its first positional arg. Keeping the caller-side signal positional-ish (withSignal(s).dispatch(...)) mirrors that.

Against:

  • withSignal(signal).dispatch(...) is genuinely more typing and more indirection than dispatch(...args, { signal }) at the most common call site (one-shot per-attempt timeout). The bind-once case is real but probably rarer than the one-shot.
  • Two places to attach a signal (reactiveStore({ signal }) for the initial fire, withSignal for subsequent ones) is a bit of a wart — but it falls out of the fact that the initial fire has no explicit call site, not out of the withSignal design itself.

On the alternative you're floating (each dispatchable function responsible for its own signal, no store-level plumbing): that works for the bare createReactiveActionStore(fn) case where you control fn's signature, but it doesn't work for PendingRpcRequest.reactiveStore() — the args are baked in by the wrapper, so dispatch() is () => void and there's nowhere for the caller to inject a signal short of widening the API. You'd end up needing something like withSignal or dispatch({ signal }) to attach one externally. The current design picks the former.

So I think the real question to weigh is whether the bind-once ergonomic + the variadic-tuple-friendliness of withSignal is worth the extra hop at the common call site, vs. moving to dispatch(...args, { signal }) and accepting a slightly less clean kill-switch story. Both seem defensible to me.

@mcintyre94 mcintyre94 marked this pull request as draft May 13, 2026 10:04
@mcintyre94 mcintyre94 changed the base branch from react/use-action to graphite-base/1614 May 13, 2026 10:32
@mcintyre94 mcintyre94 force-pushed the reactive-store/abort-signal branch from 1c2b3e9 to 3471172 Compare May 13, 2026 10:32
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1614 to reactive-store/no-auto-dispatch May 13, 2026 10:32
@mcintyre94 mcintyre94 changed the title Add perRequestSignal factory option to reactive action store Add withSignal to reactive action store for per-dispatch cancellation May 13, 2026
@mcintyre94
Copy link
Copy Markdown
Member Author

With #1628 this PR is simplified, and no longer needs to change the RpcPendingRequest.reactiveStore() API, which no longer auto-dispatches and so doesn't need a signal when called.

@trevor-cortex Please do a fresh code review of this PR as-is. Earlier comments refer to a previous version of the API

@trevor-cortex
Copy link
Copy Markdown

Fresh review — LGTM (commenting since I don't have write access). Scope is tight and the API is materially cleaner than where this started.

What changed since the prior round

  • Factory dropped; the surface is now store.withSignal(signal).{ dispatch, dispatchAsync }. Internally a single dispatchAsyncWithSignal(userSignal, ...args) core, with bare dispatchAsync just calling it with undefined. Minimal diff against the old code path — nice.
  • PendingRpcRequest.reactiveStore() is now () => ReactiveActionStore<[], TResponse> with no options object, matching Drop auto-fire from PendingRpcRequest.reactiveStore() #1628 dropping auto-dispatch. Signal handling is now uniform: reactiveStore().withSignal(sig).dispatch().
  • Polyfill unchanged; still scoped to a single static-method patch on jsdom's AbortSignal.

Behavior trace (for the record)

The two abort-check sites in dispatchAsyncWithSignal interact cleanly:

  • User signal abortsAbortSignal.any propagates → either the post-await if (signal.aborted) throw signal.reason fires, or getAbortablePromise rejects with the same reason → catch sees controller.signal.aborted === false → falls through to setState({ status: 'error' }). ✅
  • Controller aborts (supersession or reset()) → combined signal aborts → catch sees controller.signal.aborted === true → re-throws controller.signal.reason and skips the setState, so only the newer dispatch's outcome reaches state. ✅
  • Already-aborted user signal at dispatch timeAbortSignal.any returns already-aborted → getAbortablePromise rejects → user-signal path → error state. ✅ (matches the 'lets later dispatches recover when a prior call started with an already-aborted signal' test.)

This was the load-bearing thing I asked about in the prior round and it's still correct under the simplified shape.

Notes for subsequent reviewers

  • Wrapper is non-memoized — each store.withSignal(sig) returns a fresh { dispatch, dispatchAsync } object. That's by design and the docs explicitly tell kill-switch users to bind once (const killable = store.withSignal(killCtrl.signal)). Worth being aware of if anyone passes store.withSignal(...) directly through React render without memoization, but it's harmless — the wrapper isn't tracked as a dep anywhere meaningful.
  • withSignal(signal: AbortSignal) is non-optional. Callers threading an optional signal through (e.g. signal?: AbortSignal) have to branch between store.withSignal(sig).dispatch() and store.dispatch(). Widening to AbortSignal | undefined and falling back to bare dispatch internally would remove that branch, but it's a minor ergonomics call — easy follow-up if it bites.
  • Doc wording nit on withSignal's JSDoc — says "aborting either cancels the in-flight call and surfaces the abort reason on state." Strictly true only for the user signal; the internal controller path (supersession / reset()) is intentionally silent so the newer dispatch owns state. The asymmetry is correct and documented on dispatch itself, just slightly elided here. Worth a one-line tweak; not blocking.
  • Changeset is @solana/subscribable minor only. The rpc-spec change is JSDoc-only (no API or behavior change), so omitting it is correct. Pre-1.0 minor still treated as breaking per repo convention — reviewers should sanity-check downstream consumers of reactiveStore()'s old (options) signature, since that signature is gone now (per Drop auto-fire from PendingRpcRequest.reactiveStore() #1628).
  • Test coverage is solid — seven cases over withSignal: signal-forwarded-to-fn, error transition, stale-data preservation, recovery after already-aborted, combined-signal observable by fn, varying signals across dispatches, and kill-switch reuse. Good spread; nothing obvious missing.
  • Polyfill listener cleanupaddEventListener('abort', ..., { once: true, signal: controller.signal }) removes input-signal listeners once the combined signal aborts. No leak when a long-lived user signal is composed into many short-lived dispatches.

@mcintyre94 mcintyre94 force-pushed the reactive-store/abort-signal branch from 3471172 to f22b562 Compare May 13, 2026 10:43
@mcintyre94 mcintyre94 marked this pull request as ready for review May 13, 2026 11:24
@mcintyre94 mcintyre94 force-pushed the reactive-store/abort-signal branch 2 times, most recently from 674c112 to f2bb5d1 Compare May 18, 2026 14:42
@mcintyre94 mcintyre94 force-pushed the reactive-store/no-auto-dispatch branch from 23bc249 to 5fac7a4 Compare May 18, 2026 14:42
Copy link
Copy Markdown
Member

@lorisleiva lorisleiva left a comment

Choose a reason for hiding this comment

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

Nice!

Comment on lines +19 to +22
const JsdomAbortSignal = this.global.AbortSignal as typeof globalThis.AbortSignal & {
any?: typeof globalThis.AbortSignal.any;
};
const JsdomAbortController = this.global.AbortController as typeof globalThis.AbortController;
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.

Should we consider bumping jsdom to version 24 in a subsequent PR?

…tion

`ReactiveActionStore` now exposes `withSignal(signal)` — a thin wrapper that returns `{ dispatch, dispatchAsync }` bindings composing the caller's signal with the store's internal per-dispatch controller via `AbortSignal.any`. Aborting either cancels the in-flight call and surfaces the abort reason on state. The bare `dispatch` / `dispatchAsync` signatures are unchanged, so this is additive — no existing caller breaks.

The wrapper supports two patterns. Per-attempt timeout: `store.withSignal(AbortSignal.timeout(5_000)).dispatch(args)` — a fresh clock per call, with different call sites free to pass different timeouts without rebuilding the store. Shared kill switch: hold one `AbortController`, bind the wrapper once (`const killable = store.withSignal(killCtrl.signal)`), use `killable.dispatch(...)` everywhere; aborting the controller cancels the in-flight call and short-circuits future calls on that wrapper.

`PendingRpcRequest.reactiveStore()` (and the `ReactiveActionSource` duck-type) also accepts an optional `{ signal?: AbortSignal }` so a caller-provided cancellation source can be attached to the initial dispatch fired implicitly by `reactiveStore()` — same role `abortSignal` plays for `send()`. The `signal` is not a store-level setting; subsequent dispatches on the returned store go through `store.withSignal(...).dispatch(...)` like any other store.

The shared `@solana/test-config` browser environment polyfills `AbortSignal.any` because jsdom 22 (the version pinned here) doesn't ship it. Replacing the AbortSignal class wholesale would break jsdom's brand checks for `addEventListener({ signal })`, so the patch is limited to the missing static method.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants