Skip to content

Add retry and getUnifiedState to ReactiveStore#1552

Merged
mcintyre94 merged 1 commit into
mainfrom
reactive-store-updates
May 7, 2026
Merged

Add retry and getUnifiedState to ReactiveStore#1552
mcintyre94 merged 1 commit into
mainfrom
reactive-store-updates

Conversation

@mcintyre94
Copy link
Copy Markdown
Member

@mcintyre94 mcintyre94 commented Apr 21, 2026

Problem

ReactiveStore exposed getState() and getError() as separate getters with no unified snapshot, which made useSyncExternalStore integration awkward. Errors were also terminal — once a stream failed, the only recovery was to rebuild the whole store, losing subscribers and last-known state in the process.

Summary of Changes

This PR brings ReactiveStore closer to the proposed ActionStore: #1550. See discussion: #1550 (review)

  • ReactiveStore<T> gains retry(): void and getUnifiedState(): ReactiveState<T>. ReactiveState<T> is a discriminated union over loading | loaded | error | retrying with stable snapshot identity across reads. The existing getState() and getError() getters are preserved but marked @deprecated. A future breaking change can make getState() the new getUnifiedState
  • New createReactiveStoreFromDataPublisherFactory accepts a () => Promise<DataPublisher> factory and supports real retry, wiring a fresh inner abort signal for each connection attempt.
  • The existing createReactiveStoreFromDataPublisher is now @deprecated. Its retry() throws a new SolanaError with code SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED since a ready-made DataPublisher can't be restarted.
  • createReactiveStoreWithInitialValueAndSlotTracking (in @solana/kit) now implements retry() by re-invoking the pending RPC request and subscription on a new inner abort signal, preserving lastUpdateSlot and the last known value.
  • Updated tests for both factories and the slot-tracking store; updated the subscribable README.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: cb4c655

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

@bundlemon
Copy link
Copy Markdown

bundlemon Bot commented Apr 21, 2026

BundleMon

Files updated (13)
Status Path Size Limits
subscribable/dist/index.native.mjs
2.26KB (+354B +18.03%) -
subscribable/dist/index.browser.mjs
2.26KB (+353B +18.03%) -
subscribable/dist/index.node.mjs
2.32KB (+353B +17.48%) -
kit/dist/index.browser.mjs
3.97KB (+253B +6.63%) -
kit/dist/index.native.mjs
3.97KB (+251B +6.58%) -
kit/dist/index.node.mjs
3.97KB (+251B +6.58%) -
@solana/kit production bundle
kit/dist/index.production.min.js
51.75KB (+168B +0.32%) -
errors/dist/index.browser.mjs
20.52KB (+103B +0.49%) -
errors/dist/index.native.mjs
20.51KB (+103B +0.49%) -
errors/dist/index.node.mjs
20.54KB (+103B +0.49%) -
wallet-account-signer/dist/index.browser.mjs
17.36KB (+90B +0.51%) -
wallet-account-signer/dist/index.native.mjs
17.36KB (+90B +0.51%) -
wallet-account-signer/dist/index.node.mjs
17.38KB (+90B +0.51%) -
Unchanged files (134)
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 -
react/dist/index.browser.mjs
3.09KB -
react/dist/index.native.mjs
3.09KB -
react/dist/index.node.mjs
3.09KB -
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.41KB -
sysvars/dist/index.browser.mjs
2.37KB -
sysvars/dist/index.native.mjs
2.37KB -
sysvars/dist/index.node.mjs
2.37KB -
transaction-confirmation/dist/index.native.mj
s
2.36KB -
transaction-confirmation/dist/index.browser.m
js
2.35KB -
rpc-subscriptions-spec/dist/index.node.mjs
2.21KB -
rpc-subscriptions-spec/dist/index.native.mjs
2.17KB -
rpc-subscriptions-spec/dist/index.browser.mjs
2.16KB -
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
976B -
rpc-api/dist/index.native.mjs
975B -
rpc-api/dist/index.node.mjs
973B -
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 -
rpc-spec/dist/index.browser.mjs
852B -
rpc-spec/dist/index.native.mjs
851B -
rpc-spec/dist/index.node.mjs
850B -
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 +2.5KB +0.48%

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.

Nice, focused evolution of ReactiveStore toward the ActionStore shape. The unified { data, error, status } snapshot with stable identity is exactly what useSyncExternalStore needs, and preserving the existing getters (deprecated) keeps this non-breaking. Factoring the factory variant out as a separate function rather than overloading the existing one is the right call — the semantics of retry() differ enough to justify the split.

One correctness bug worth fixing before merge, plus a few smaller notes below.

Things to watch out for

  • retry() after the outer abort leaves the store stuck in retrying (see inline). The guard is in connect() but retry() has already transitioned state by the time it's hit. Both the factory variant and the slot-tracking variant have this.
  • Asymmetric retry() behaviour across factories. The deprecated createReactiveStoreFromDataPublisher throws from retry(); the factory variant is a silent no-op when not in error. Defensible (the throw surfaces the deprecation loudly), but it means a React component that wires retry to a button can crash if it happens to be rendered against a non-retryable store. Worth flagging more prominently in the README snippet for the deprecated function.
  • lastUpdateSlot is preserved across retry in the slot-tracking store (intentional and documented). Be aware this means a retry whose RPC + subscription both initially return slots < lastUpdateSlot leaves the store stuck in retrying until the subscription catches up. In practice that window is tiny, but it's worth a brief comment at the site of the slot check.

Notes for subsequent reviewers

  • The inner/outer abort-controller pattern in the factory variant is the trickiest part to eyeball. Specifically worth re-checking: (a) stale listeners from a prior connection are aborted before retry() wires up the new one, and (b) the outerController.signal.addEventListener('abort', forwardAbort, { signal: innerController.signal }) self-unregistration pattern (so we don't leak one listener per retry onto the outer signal). Both looked correct to me, but they're load-bearing.
  • Test coverage for the new API is thorough across all three sites. The one gap I'd suggest is that the "retry after abort is a no-op" tests only assert call counts, not the resulting status — which is why they pass despite the state-machine bug flagged below.
  • Changeset bump is minor across @solana/errors, @solana/subscribable, @solana/kit — matches the CLAUDE.md convention for new APIs. The existing getters are only @deprecated (not removed), so this is additive.
  • Error code SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED = 8195000 opens a new reserved range [8195000-8195999] for the SUBSCRIBABLE domain. Looks consistent with the existing registry layout.

Comment thread packages/subscribable/src/reactive-store.ts
Comment thread packages/subscribable/src/reactive-store.ts
@@ -105,7 +117,9 @@ Things to note:

### `createReactiveStoreFromDataPublisher({ abortSignal, dataChannelName, dataPublisher, errorChannelName })`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor, but worth making the crash behaviour more prominent here: readers might skim the note and assume "deprecated but still works everywhere", then wire onRetry={store.retry} to a button (matching the React example in the ReactiveStore<T> section above) and get an uncaught SolanaError at click-time.

Consider hoisting the "calling retry() throws" clause to its own sentence (or bold fragment) so it reads more like a warning than a footnote, or mention it in the React example for the type itself. Not a blocker.

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.

I think this is clear enough in practice
Screenshot 2026-04-21 at 18 04 41

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

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

@mcintyre94 mcintyre94 force-pushed the reactive-store-updates branch 2 times, most recently from 13959e2 to 4baf015 Compare April 21, 2026 16:08
Copy link
Copy Markdown
Member Author

mcintyre94 commented Apr 21, 2026

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.

Reviewed this the wrong way around but I get what you're doing haha. Nice changes!

@mcintyre94 mcintyre94 force-pushed the reactive-store-updates branch from 4baf015 to 1d06dcc Compare April 23, 2026 09:39
`ReactiveStore<T>` now exposes a `getUnifiedState()` method returning a discriminated `{ data, error, status }` snapshot over `loading | loaded | error | retrying`. The snapshot has stable identity across reads, so stores can be passed directly to `useSyncExternalStore` without an intermediate wrapper. `getState()` and `getError()` remain on the type but are deprecated in favour of the unified snapshot.

A new `createReactiveStoreFromDataPublisherFactory` accepts a `() => Promise<DataPublisher>` factory and supports real `retry()` by wiring a fresh inner abort signal for each connection attempt. The existing `createReactiveStoreFromDataPublisher` is deprecated; its `retry()` throws a new `SolanaError` with code `SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED` because a ready-made `DataPublisher` can't be restarted.

In `@solana/kit`, `createReactiveStoreWithInitialValueAndSlotTracking` now implements `retry()` by re-invoking the pending RPC request and subscription on a new inner abort signal, preserving `lastUpdateSlot` and the last known value.
@mcintyre94 mcintyre94 force-pushed the reactive-store-updates branch from 1d06dcc to cb4c655 Compare May 7, 2026 13:25
Copy link
Copy Markdown
Member Author

mcintyre94 commented May 7, 2026

Merge activity

  • May 7, 2:56 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • May 7, 2:57 PM UTC: @mcintyre94 merged this pull request with Graphite.

@mcintyre94 mcintyre94 merged commit c318d7f into main May 7, 2026
14 checks passed
@mcintyre94 mcintyre94 deleted the reactive-store-updates branch May 7, 2026 14:57
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

🔎💬 Inkeep AI search and chat service is syncing content for source 'Solana Kit Docs'

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