Skip to content

Commit efde42d

Browse files
committed
Collapse loading and retrying into a single loading status
Mirrors the action store's `running` (which already merged "first call vs subsequent call"). `data` and `error` are preserved through `loading` for stale-while-revalidate UX. `ReactiveState<T>` drops the `retrying` variant. `loading` widens from `{ data: undefined, error: undefined }` to `{ data: T | undefined, error: unknown }`. Both `createReactiveStoreFromDataPublisherFactory` and `createReactiveStoreWithInitialValueAndSlotTracking` transition every `connect()` through `loading`, preserving `currentState.data` and `currentState.error`. Consumers that need to distinguish first-load from reconnect inspect `data === undefined` instead of relying on a separate `retrying` status. Bumps `@solana/subscribable` and `@solana/kit` major. Tests in both packages updated: `retrying`-state assertions now assert `loading` carrying stale data + error. Downstream `useSubscription` (next commit) drops `retrying` from `SubscriptionResult.status` and passes data + error through the `loading` case.
1 parent 85182fc commit efde42d

6 files changed

Lines changed: 72 additions & 58 deletions

File tree

.changeset/odd-bobcats-see.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@solana/subscribable': major
3+
'@solana/kit': major
4+
---
5+
6+
Collapse `loading` and `retrying` into a single `loading` status on `ReactiveStreamStore`, mirroring the action store's `running` (which is itself the merged "first call vs subsequent call" state). `data` and `error` are preserved through `loading` for stale-while-revalidate — UI can render the prior outcome alongside an in-flight reconnect.
7+
8+
`ReactiveState<T>` drops the `retrying` variant. `loading` widens from `{ data: undefined, error: undefined }` to `{ data: T | undefined, error: unknown }`. Both `createReactiveStoreFromDataPublisherFactory` and `createReactiveStoreWithInitialValueAndSlotTracking` now transition every `connect()` through `loading` (preserving `currentState.data` and `currentState.error`); a subsequent `loaded` clears `error`, a subsequent `error` replaces it.
9+
10+
```ts
11+
// Previously:
12+
{ status: 'error', data: lastValue, error: caughtError }
13+
// connect() →
14+
{ status: 'retrying', data: lastValue, error: undefined } // error cleared, separate status
15+
16+
// Now:
17+
{ status: 'error', data: lastValue, error: caughtError }
18+
// connect() →
19+
{ status: 'loading', data: lastValue, error: caughtError } // error preserved, unified status
20+
```
21+
22+
Migration: replace `status === 'retrying'` checks with `status === 'loading' && data !== undefined` (or just `status === 'loading'` if you don't need to distinguish first-load vs reconnect — the SWR pattern lets you render whatever is in `data` regardless).

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => {
667667
store.retry();
668668
expect(rpcRequest.send).toHaveBeenCalledTimes(1);
669669
});
670-
it('transitions to `retrying` with preserved data and clears the error', async () => {
670+
it('transitions back to `loading` with preserved data AND error (SWR)', async () => {
671671
expect.assertions(1);
672672
const { rpcInstances, rpcRequest, rpcSubscriptionRequest, subscriptionInstances } = createRetryableMocks();
673673
const store = createReactiveStoreWithInitialValueAndSlotTracking({
@@ -679,13 +679,14 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => {
679679
store.connect();
680680
rpcInstances[0].resolve(rpcResponse(100, { count: 42 }));
681681
await jest.runAllTimersAsync();
682-
subscriptionInstances[0].error(new Error('stream died'));
682+
const fail = new Error('stream died');
683+
subscriptionInstances[0].error(fail);
683684
await jest.runAllTimersAsync();
684685
store.retry();
685686
expect(store.getUnifiedState()).toStrictEqual({
686687
data: { context: { slot: 100n }, value: 42 },
687-
error: undefined,
688-
status: 'retrying',
688+
error: fail,
689+
status: 'loading',
689690
});
690691
});
691692
it('re-invokes the RPC request and subscription on retry', async () => {
@@ -750,7 +751,7 @@ describe('createReactiveStoreWithInitialValueAndSlotTracking', () => {
750751
status: 'error',
751752
});
752753
});
753-
it('notifies subscribers on the retrying transition', async () => {
754+
it('notifies subscribers on the error → loading transition after retry', async () => {
754755
expect.assertions(1);
755756
const { rpcInstances, rpcRequest, rpcSubscriptionRequest } = createRetryableMocks();
756757
const store = createReactiveStoreWithInitialValueAndSlotTracking({

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

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ const IDLE_STATE: ReactiveState<never> = Object.freeze({
5757
* - The returned store starts in `status: 'idle'`. Call
5858
* {@link ReactiveStreamStore.connect | `connect()`} to fire the RPC request and open the
5959
* subscription.
60-
* - From `idle`, the store transitions through `loading` until the first response or notification
61-
* arrives, then to `loaded` with a {@link SolanaRpcResponse} containing the value and the slot
62-
* context at which it was observed.
60+
* - The store transitions through `loading` until the first response or notification arrives,
61+
* then to `loaded` with a {@link SolanaRpcResponse} containing the value and the slot context
62+
* at which it was observed.
6363
* - On error from either source, the store transitions to `status: 'error'` preserving the last
6464
* known value. Only the first error per connection window is captured.
65-
* - A subsequent `connect()` after `loaded` or `error` aborts the current connection, transitions
66-
* through `status: 'retrying'` (preserving stale data), and re-fires the RPC request and
67-
* subscription with a fresh inner abort signal.
65+
* - A subsequent `connect()` aborts the current connection, transitions back to
66+
* `status: 'loading'` (preserving the last known `data` and `error` for stale-while-revalidate),
67+
* and re-fires the RPC request and subscription with a fresh inner abort signal.
6868
* - {@link ReactiveStreamStore.reset | `reset()`} aborts the current connection and returns the
6969
* store to `idle`, clearing `data` and `error`.
7070
* - Attach a caller-provided cancellation source via
@@ -145,12 +145,9 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
145145
setState({ data: currentState.data, error: callerSignal.reason, status: 'error' });
146146
return;
147147
}
148-
// Transition based on whether we have prior data/error to preserve.
149-
if (currentState.status === 'idle') {
150-
setState({ data: undefined, error: undefined, status: 'loading' });
151-
} else {
152-
setState({ data: currentState.data, error: undefined, status: 'retrying' });
153-
}
148+
// Transition to `loading`, preserving the last known `data` and `error` for SWR. If
149+
// already `loading` with the same data/error, `setState` no-ops — no spurious notify.
150+
setState({ data: currentState.data, error: currentState.error, status: 'loading' });
154151

155152
const innerController = new AbortController();
156153
currentInnerController = innerController;
@@ -186,9 +183,9 @@ export function createReactiveStoreWithInitialValueAndSlotTracking<TRpcValue, TS
186183
.send({ abortSignal: signal })
187184
.then(({ context: { slot }, value }) => {
188185
if (signal.aborted) return;
189-
// `lastUpdateSlot` persists across retries so the store never regresses. If the
190-
// retried RPC returns a slot older than one we've already seen, we wait for the
191-
// subscription to deliver something newer before leaving `retrying`.
186+
// `lastUpdateSlot` persists across reconnects so the store never regresses. If
187+
// the re-fetched RPC returns a slot older than one we've already seen, we wait
188+
// for the subscription to deliver something newer before leaving `loading`.
192189
if (slot < lastUpdateSlot) return;
193190
lastUpdateSlot = slot;
194191
handleValue({ context: { slot }, value: rpcValueMapper(value) });

packages/subscribable/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ type ReactiveState<T> =
3838
| { data: undefined; error: undefined; status: 'loading' }
3939
| { data: T; error: undefined; status: 'loaded' }
4040
| { data: T | undefined; error: unknown; status: 'error' }
41-
| { data: T | undefined; error: undefined; status: 'retrying' }
41+
| { data: T | undefined; error: unknown; status: 'retrying' }
4242
| { data: undefined; error: undefined; status: 'idle' };
4343
```
4444

4545
> Also exported as `ReactiveStore<T>` for backwards compatibility. That alias is deprecated and will be removed in a future major release.
4646
47-
The store starts in `status: 'idle'`. Call `connect()` to open the underlying stream; the store will transition through `loading``loaded` (or `error`). A subsequent `connect()` after `loaded` or `error` transitions through `retrying` while preserving the last known value. Call `reset()` to tear down the connection and return to `idle` without permanently killing the store.
47+
The store starts in `status: 'idle'`. Call `connect()` to open the underlying stream; the store will transition through `loading``loaded` (or `error`). A subsequent `connect()` after `loaded` or `error` transitions through `retrying` while preserving the last known value and the last error (stale-while-revalidate). A subsequent `loaded` clears the error; a subsequent `error` replaces it. Call `reset()` to tear down the connection and return to `idle` without permanently killing the store.
4848

4949
```ts
5050
const store: ReactiveStreamStore<AccountInfo> = /* ... */;

packages/subscribable/src/__tests__/reactive-stream-store-test.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('createReactiveStoreFromDataPublisherFactory', () => {
122122
status: 'error',
123123
});
124124
});
125-
it('from `error`, transitions through `retrying` preserving stale data', async () => {
125+
it('from `error`, transitions back through `loading` preserving stale data AND error (SWR)', async () => {
126126
expect.assertions(1);
127127
const { mockRequest, publishers } = createFactory();
128128
const store = createReactiveStoreFromDataPublisherFactory({
@@ -133,15 +133,16 @@ describe('createReactiveStoreFromDataPublisherFactory', () => {
133133
store.connect();
134134
await jest.runAllTimersAsync();
135135
publishers[0].publish('data', { value: 42 });
136-
publishers[0].publish('error', new Error('fail'));
136+
const fail = new Error('fail');
137+
publishers[0].publish('error', fail);
137138
store.connect();
138139
expect(store.getUnifiedState()).toStrictEqual({
139140
data: { value: 42 },
140-
error: undefined,
141-
status: 'retrying',
141+
error: fail,
142+
status: 'loading',
142143
});
143144
});
144-
it('from `loaded`, transitions through `retrying` preserving the last value', async () => {
145+
it('from `loaded`, transitions back through `loading` preserving the last value', async () => {
145146
expect.assertions(1);
146147
const { mockRequest, publishers } = createFactory();
147148
const store = createReactiveStoreFromDataPublisherFactory({
@@ -156,7 +157,7 @@ describe('createReactiveStoreFromDataPublisherFactory', () => {
156157
expect(store.getUnifiedState()).toStrictEqual({
157158
data: { value: 42 },
158159
error: undefined,
159-
status: 'retrying',
160+
status: 'loading',
160161
});
161162
});
162163
it('invokes the factory again on each connect()', async () => {
@@ -193,7 +194,7 @@ describe('createReactiveStoreFromDataPublisherFactory', () => {
193194
status: 'loaded',
194195
});
195196
});
196-
it('notifies subscribers on the retrying transition', async () => {
197+
it('notifies subscribers on the loaded → loading transition after reconnect', async () => {
197198
expect.assertions(1);
198199
const { mockRequest, publishers } = createFactory();
199200
const store = createReactiveStoreFromDataPublisherFactory({
@@ -402,7 +403,7 @@ describe('createReactiveStoreFromDataPublisherFactory', () => {
402403
expect(mockRequest).toHaveBeenCalledTimes(callsBefore);
403404
expect(store.getUnifiedState().status).toBe('loading');
404405
});
405-
it('transitions to `retrying` from `error`', async () => {
406+
it('transitions back to `loading` from `error`, preserving stale data and error (SWR)', async () => {
406407
expect.assertions(1);
407408
const { mockRequest, publishers } = createFactory();
408409
const store = createReactiveStoreFromDataPublisherFactory({
@@ -413,12 +414,13 @@ describe('createReactiveStoreFromDataPublisherFactory', () => {
413414
store.connect();
414415
await jest.runAllTimersAsync();
415416
publishers[0].publish('data', { value: 42 });
416-
publishers[0].publish('error', new Error('fail'));
417+
const fail = new Error('fail');
418+
publishers[0].publish('error', fail);
417419
store.retry();
418420
expect(store.getUnifiedState()).toStrictEqual({
419421
data: { value: 42 },
420-
error: undefined,
421-
status: 'retrying',
422+
error: fail,
423+
status: 'loading',
422424
});
423425
});
424426
});

packages/subscribable/src/reactive-stream-store.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,18 @@ type FactoryConfig = Readonly<{
3737
* - `idle`: the store has not yet been connected, or has been reset via
3838
* {@link ReactiveStreamStore.reset | `reset()`}. Call
3939
* {@link ReactiveStreamStore.connect | `connect()`} to open the underlying stream.
40-
* - `loading`: a first connection is in progress; no data has arrived yet.
40+
* - `loading`: a connection is in progress. `data` and `error` are preserved from the previous
41+
* connection (if any) — stale-while-revalidate UX. A subsequent `loaded` clears `error`; a
42+
* subsequent `error` replaces it.
4143
* - `loaded`: a value has been received and no error is active.
4244
* - `error`: the stream failed. `data` holds the last known value (or `undefined` if none ever
4345
* arrived) and `error` holds the failure.
44-
* - `retrying`: a follow-up `connect()` is in progress after a previous outcome. `error` is
45-
* cleared; `data` is preserved from the previous connection if any.
4646
*/
4747
export type ReactiveState<T> =
48-
| { readonly data: T | undefined; readonly error: undefined; readonly status: 'retrying' }
4948
| { readonly data: T | undefined; readonly error: unknown; readonly status: 'error' }
49+
| { readonly data: T | undefined; readonly error: unknown; readonly status: 'loading' }
5050
| { readonly data: T; readonly error: undefined; readonly status: 'loaded' }
51-
| { readonly data: undefined; readonly error: undefined; readonly status: 'idle' }
52-
| { readonly data: undefined; readonly error: undefined; readonly status: 'loading' };
51+
| { readonly data: undefined; readonly error: undefined; readonly status: 'idle' };
5352

5453
const IDLE_STATE: ReactiveState<never> = Object.freeze({
5554
data: undefined,
@@ -63,9 +62,9 @@ const IDLE_STATE: ReactiveState<never> = Object.freeze({
6362
* `from()`, and other reactive primitives that expect a `{ subscribe, getUnifiedState }` contract.
6463
*
6564
* The store starts in `status: 'idle'`. Call {@link ReactiveStreamStore.connect | `connect()`}
66-
* to open the underlying stream; the store will then transition through `loading` → `loaded` (or
67-
* `error`). A subsequent `connect()` after `loaded` or `error` transitions through `retrying`
68-
* while preserving the last known value.
65+
* to open the underlying stream; the store transitions through `loading` → `loaded` (or `error`).
66+
* Subsequent `connect()` calls also pass through `loading` while preserving the last known
67+
* `data` and `error` (stale-while-revalidate).
6968
*
7069
* @example
7170
* ```ts
@@ -86,9 +85,9 @@ const IDLE_STATE: ReactiveState<never> = Object.freeze({
8685
export type ReactiveStreamStore<T> = {
8786
/**
8887
* Open the underlying stream. Aborts any currently active connection, invokes the configured
89-
* factory, and transitions the store through `loading` (when called from `idle` or while a
90-
* connection is already in flight) or `retrying` (when called after a previous outcome —
91-
* i.e. `loaded` or `error`) before settling into `loaded` (on data) or `error` (on failure).
88+
* factory, and transitions the store to `loading` (preserving the last known `data` and
89+
* `error` for stale-while-revalidate) before settling into `loaded` (on data) or `error`
90+
* (on failure).
9291
*/
9392
connect(): void;
9493
/**
@@ -104,7 +103,7 @@ export type ReactiveStreamStore<T> = {
104103
* notification has arrived yet. On error, continues to return the last known value.
105104
*
106105
* @deprecated Use {@link ReactiveStreamStore.getUnifiedState | `getUnifiedState()`} instead. This
107-
* getter returns only the value field and does not surface lifecycle status (e.g. `retrying`).
106+
* getter returns only the value field and does not surface lifecycle status (e.g. `loading`).
108107
*/
109108
getState(): T | undefined;
110109
/**
@@ -204,9 +203,8 @@ export type ReactiveStreamSource<T> = {
204203
* Things to note:
205204
*
206205
* - The returned store starts in `status: 'idle'`. Call `connect()` to open the first stream.
207-
* - `createDataPublisher` is invoked on every `connect()`. From `idle`, the store transitions
208-
* through `loading`; from any other status, through `retrying` while preserving the last
209-
* known value.
206+
* - `createDataPublisher` is invoked on every `connect()`. The store transitions through
207+
* `loading`, preserving the last known `data` and `error` (stale-while-revalidate).
210208
* - If `createDataPublisher` rejects, the store transitions to `status: 'error'` with the
211209
* rejection as the error. Call `connect()` to try again.
212210
* - `reset()` aborts the current connection and returns the store to `idle`, clearing `data`
@@ -267,15 +265,9 @@ export function createReactiveStoreFromDataPublisherFactory<TData>({
267265
setState({ data: currentState.data, error: callerSignal.reason, status: 'error' });
268266
return;
269267
}
270-
// Transition based on whether we have a prior outcome to preserve. If already `loading`,
271-
// a connection is in flight — we've just aborted it and will rewire to a fresh factory
272-
// invocation below, but there's no user-visible value yet, so stay in `loading` rather
273-
// than detour through `retrying`.
274-
if (currentState.status === 'idle') {
275-
setState({ data: undefined, error: undefined, status: 'loading' });
276-
} else if (currentState.status !== 'loading') {
277-
setState({ data: currentState.data, error: undefined, status: 'retrying' });
278-
}
268+
// Transition to `loading`, preserving the last known `data` and `error` for SWR. If
269+
// already `loading` with the same data/error, `setState` no-ops — no spurious notify.
270+
setState({ data: currentState.data, error: currentState.error, status: 'loading' });
279271
// Inner signal is passed to the data publisher (composed with caller signal if any).
280272
const innerController = new AbortController();
281273
currentInnerController = innerController;

0 commit comments

Comments
 (0)