Add useSubscription for rendering based on a subscription#1702
Add useSubscription for rendering based on a subscription#1702mcintyre94 wants to merge 1 commit into
useSubscription for rendering based on a subscription#1702Conversation
🦋 Changeset detectedLatest commit: af665ca The changes in this PR will be included in the next version bump. This PR includes changesets to release 47 packages
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 |
BundleMonFiles updated (25)
Unchanged files (122)
Total files change +4.32KB +0.82% Final result: ✅ View report in BundleMon website ➡️ |
|
Documentation Preview: https://kit-docs-oyimd4p4b-anza-tech.vercel.app |
trevor-cortex
left a comment
There was a problem hiding this comment.
Adds a useSubscription hook that is a near-perfect mirror of useRequest for ReactiveStreamSources, plus a disabledStreamStore companion for the null-source case. Composition is clean: useSubscription owns lifecycle (memo store, effect connect+reset, ref-synced getAbortSignal, stable reconnect), useSubscriptionResult does the useSyncExternalStore bridge and the SolanaRpcResponse unwrap via splitSolanaRpcResponse. The pattern matches useRequest / useRequestResult so closely that someone who already understands one can read the other immediately — which is exactly the right outcome here.
Things to watch out for:
index.tsexport ordering is broken.useSubscriptionis inserted betweenuseSignAndSendTransactionanduseSignIn, which puts it out of alphabetical order with theuseSign*cluster. Should sit afteruseSignTransactioninstead. Trivial fix; flagging inline.- SSR snapshot getter passed to
useSyncExternalStore.useSubscriptionResultpassesstore.getUnifiedStateas both the client and server snapshot getter — that's what makes the SSR test pass without React warning about a missing server snapshot, and it's only correct becauseIDLE_STATE/IDLE_STREAM_STATEare frozen singletons with stable identity. Worth noting thatuseRequestResultdoesn't currently pass a server snapshot at all, so there's a small inconsistency between the two bridges — not a blocker for this PR, but probably worth a follow-up to bringuseRequestResultin line (or document why it doesn't need it). slotafterloaded→error. The docblock forSubscriptionResult.slotlists when it'sundefined(loading / disabled / server / error-only). After a successfulloadedthat transitions toerror, the stale envelope is preserved instate.dataandsplitSolanaRpcResponsewill keep returning the staleslot. That's the right SWR behaviour, and the docs aren't wrong, but the "when only an error has arrived" qualifier is doing more work than it looks — readers may assumeerroralways nukesslot. Optional: a short note thatslotfollowsdata(stale onerrorafter a priorloaded).
Notes for subsequent reviewers:
- The fake subscription helper in
useSubscription-test.browser.tsxrelies on the store binding both thedataanderrorchannels via.on()on everyconnect()— it counts to 2 before resolvingpublisherReady. IfcreateReactiveStoreFromDataPublisherFactory's binding order or channel count ever changes, that handshake silently deadlocks. Worth a peek if you're touching the underlying store. - The
useEffect([store])cleanup ordering matters under StrictMode and source identity changes: cleanup runsstore.reset()on the previous store closure, then the new store'sconnect()fires. The fake's listener-array reset insidecreateDataPublisheris what makes the "aborts the prior connection's listeners when the source identity changes" test pass cleanly — confirmed by reading through; just calling out where the invariant lives in case it's not obvious. - The
disabledStreamStoretests are thorough (frozen state, no-opconnect/reset/retry/withSignal, no-notifysubscribe). Coverage of the disabled path is solid. - Typetest covers the unwrap on both envelope and raw shapes, the null-source case, the presence-based
reconnectAPI, and asserts the status union doesn't leak the action-store'success'vocabulary. Good signal-to-noise.
| reconnect: (options?: { abortSignal?: AbortSignal | undefined }) => void, | ||
| disabled: boolean, | ||
| ): SubscriptionResult<UnwrapRpcResponse<T>> { | ||
| const state = useSyncExternalStore(store.subscribe, store.getUnifiedState, store.getUnifiedState); |
There was a problem hiding this comment.
Worth noting that this passes store.getUnifiedState as the server snapshot (3rd arg) — useRequestResult doesn't. The reason this is correct here is that idle state is a frozen singleton with stable identity, so the server and client snapshots match on first paint and useSyncExternalStore doesn't warn. Probably worth a follow-up to bring useRequestResult in line (its current 2-arg form likely triggers a React SSR warning in dev). Not a blocker for this PR.
There was a problem hiding this comment.
Updated useAction and useRequest to use the same pattern in their PRs
cc6b909 to
72a4b00
Compare
8dcf3dd to
3aa3b8a
Compare
`useSubscription(source)` opens a stream-store subscription on mount, re-opens whenever the source identity changes, and tears it down on unmount. The source is any `ReactiveStreamSource<T>` — `PendingRpcSubscriptionsRequest` is the canonical implementation. Pass `null` to disable.
The hook mirrors `useRequest`'s structure exactly: construct the lazy store via `useMemo`, fire `store.connect()` in a `useEffect`, tear down via `store.reset()` in cleanup. Same StrictMode-safe lifecycle pattern (mount → cleanup aborts → mount re-fires), same vocabulary, same per-call signal API.
The hook returns `{ data, error, reconnect, slot, status }`. Status is one of `loading | loaded | error | disabled`. After a notification arrives, an error-channel publish transitions to `error` while preserving the stale `data`; `reconnect()` returns to `loading` (preserving stale `data` and `error` for stale-while-revalidate) before settling on `loaded` or a fresh `error`. The bridge maps the store's `idle` state to `loading` when enabled (matching the about-to-commit-effect render) and to `disabled` when the source is null.
Notifications shaped as `SolanaRpcResponse<U>` (account/program/signature) are unwrapped via `splitSolanaRpcResponse` from `@solana/rpc-types`: `data` is the inner value `U` and `slot` is lifted from `context.slot`. Raw notifications (slot/logs/root) pass through with `slot: undefined`. The `UnwrapRpcResponse<T>` conditional type (also from `@solana/rpc-types`) tracks the runtime unwrap at the type level.
Optional `getAbortSignal: () => AbortSignal` is invoked on every connection (initial subscribe + every `reconnect()`). The returned signal is composed with the store's per-connection controller via `AbortSignal.any` through `withSignal(signal).connect()`. The natural use is per-connection timeouts (`() => AbortSignal.timeout(30_000)`), which reset on reconnect. Factory is ref-synced — inline closures are fine. `reconnect()` accepts an optional `{ abortSignal }` override for one specific attempt; presence-based semantics distinguish "use factory" (omit the key), "explicit signal" (`{ abortSignal: signal }`), and "no signal" (`{ abortSignal: undefined }`).
SSR-safe: on the server the connect effect doesn't run, so the store stays `idle` and the hook reports `status: 'loading'`. The first client render hydrates from the same paint and commits the connect.
Adds `disabledStreamStore<T>()` to `staticStores.ts` — the stream-store analogue of `disabledActionStore` for the null-source case. Pinned by new tests alongside the existing `disabledActionStore` invariants.
Exports `SubscriptionResult<T>` and `UseSubscriptionOptions` for plugin hooks to build on.
72a4b00 to
af665ca
Compare

Summary of Changes
This PR adds the
useSubscriptionhook:This is a subscription analog to
useRequest. It uses thereactiveStreamStorestate machine, and immediately initialises the subscription on mount.The expected input is a
ReactiveStreamSource, the current implementation in Kit is a pending RPC subscriptions object. But eg. plugins could also publish their ownReactiveStreamSourceof the shapereactiveStore(): ReactiveStreamStoreand that would also work with this.If a subscription publishes messages with shape
SolanaRpcResponse<T>then the slot is automatically unpacked usingsplitSolanaRpcResponse, anddatais typed asT. If the subscription does not have this shape thenslotis typed as undefined, anddatais the unchanged notification.As with
useRequestthe hook takes an optionalgetAbortSignalwhich creates an abort signal per connect (initial and calls toreconnect). The call toreconnectcan optionally override this per call.