@pyreon/query 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
- import { onUnmount } from '@pyreon/core'
2
- import type { Signal } from '@pyreon/reactivity'
3
- import { batch, signal } from '@pyreon/reactivity'
1
+ import { onUnmount } from "@pyreon/core"
2
+ import type { Signal } from "@pyreon/reactivity"
3
+ import { batch, signal } from "@pyreon/reactivity"
4
4
  import type {
5
5
  DefaultError,
6
6
  MutateFunction,
7
7
  MutationObserverOptions,
8
8
  MutationObserverResult,
9
- } from '@tanstack/query-core'
10
- import { MutationObserver } from '@tanstack/query-core'
11
- import { useQueryClient } from './query-client'
9
+ } from "@tanstack/query-core"
10
+ import { MutationObserver } from "@tanstack/query-core"
11
+ import { useQueryClient } from "./query-client"
12
12
 
13
13
  export interface UseMutationResult<
14
14
  TData,
@@ -20,7 +20,7 @@ export interface UseMutationResult<
20
20
  result: Signal<MutationObserverResult<TData, TError, TVariables, TContext>>
21
21
  data: Signal<TData | undefined>
22
22
  error: Signal<TError | null>
23
- status: Signal<'idle' | 'pending' | 'success' | 'error'>
23
+ status: Signal<"idle" | "pending" | "success" | "error">
24
24
  isPending: Signal<boolean>
25
25
  isSuccess: Signal<boolean>
26
26
  isError: Signal<boolean>
@@ -28,9 +28,7 @@ export interface UseMutationResult<
28
28
  /** Fire the mutation (fire-and-forget). Errors are captured in the error signal. */
29
29
  mutate: (
30
30
  variables: TVariables,
31
- options?: Parameters<
32
- MutateFunction<TData, TError, TVariables, TContext>
33
- >[1],
31
+ options?: Parameters<MutateFunction<TData, TError, TVariables, TContext>>[1],
34
32
  ) => void
35
33
  /** Like mutate but returns a promise — use for try/catch error handling. */
36
34
  mutateAsync: MutateFunction<TData, TError, TVariables, TContext>
@@ -59,21 +57,15 @@ export function useMutation<
59
57
  options: MutationObserverOptions<TData, TError, TVariables, TContext>,
60
58
  ): UseMutationResult<TData, TError, TVariables, TContext> {
61
59
  const client = useQueryClient()
62
- const observer = new MutationObserver<TData, TError, TVariables, TContext>(
63
- client,
64
- options,
65
- )
60
+ const observer = new MutationObserver<TData, TError, TVariables, TContext>(client, options)
66
61
  const initial = observer.getCurrentResult()
67
62
 
68
63
  // Fine-grained signals: each field is independent so only effects that read
69
64
  // e.g. `mutation.isPending()` re-run when isPending changes, not on every update.
70
- const resultSig =
71
- signal<MutationObserverResult<TData, TError, TVariables, TContext>>(initial)
65
+ const resultSig = signal<MutationObserverResult<TData, TError, TVariables, TContext>>(initial)
72
66
  const dataSig = signal<TData | undefined>(initial.data)
73
67
  const errorSig = signal<TError | null>(initial.error ?? null)
74
- const statusSig = signal<'idle' | 'pending' | 'success' | 'error'>(
75
- initial.status,
76
- )
68
+ const statusSig = signal<"idle" | "pending" | "success" | "error">(initial.status)
77
69
  const isPending = signal(initial.isPending)
78
70
  const isSuccess = signal(initial.isSuccess)
79
71
  const isError = signal(initial.isError)
@@ -110,8 +102,7 @@ export function useMutation<
110
102
  // This catch prevents an unhandled promise rejection for fire-and-forget callers.
111
103
  })
112
104
  },
113
- mutateAsync: (vars, callbackOptions) =>
114
- observer.mutate(vars, callbackOptions),
105
+ mutateAsync: (vars, callbackOptions) => observer.mutate(vars, callbackOptions),
115
106
  reset: () => observer.reset(),
116
107
  }
117
108
  }
@@ -1,17 +1,22 @@
1
- import { onUnmount } from '@pyreon/core'
2
- import type { Signal } from '@pyreon/reactivity'
3
- import { effect, signal } from '@pyreon/reactivity'
1
+ import { onUnmount } from "@pyreon/core"
2
+ import type { Signal } from "@pyreon/reactivity"
3
+ import { effect, signal } from "@pyreon/reactivity"
4
4
  import type {
5
5
  DefaultError,
6
6
  QueryKey,
7
7
  QueryObserverOptions,
8
8
  QueryObserverResult,
9
- } from '@tanstack/query-core'
10
- import { QueriesObserver } from '@tanstack/query-core'
11
- import { useQueryClient } from './query-client'
9
+ } from "@tanstack/query-core"
10
+ import { QueriesObserver } from "@tanstack/query-core"
11
+ import { useQueryClient } from "./query-client"
12
12
 
13
- export type UseQueriesOptions<TQueryKey extends QueryKey = QueryKey> =
14
- QueryObserverOptions<unknown, DefaultError, unknown, unknown, TQueryKey>
13
+ export type UseQueriesOptions<TQueryKey extends QueryKey = QueryKey> = QueryObserverOptions<
14
+ unknown,
15
+ DefaultError,
16
+ unknown,
17
+ unknown,
18
+ TQueryKey
19
+ >
15
20
 
16
21
  /**
17
22
  * Subscribe to multiple queries in parallel. Returns a single signal containing
@@ -31,21 +36,17 @@ export type UseQueriesOptions<TQueryKey extends QueryKey = QueryKey> =
31
36
  * // results() — QueryObserverResult[]
32
37
  * // results()[0].data — first user
33
38
  */
34
- export function useQueries(
35
- queries: () => UseQueriesOptions[],
36
- ): Signal<QueryObserverResult[]> {
39
+ export function useQueries(queries: () => UseQueriesOptions[]): Signal<QueryObserverResult[]> {
37
40
  const client = useQueryClient()
38
41
  const observer = new QueriesObserver(client, queries())
39
42
 
40
- const resultSig = signal(
41
- observer.getCurrentResult() as readonly QueryObserverResult[],
42
- ) as Signal<QueryObserverResult[]>
43
+ const resultSig = signal(observer.getCurrentResult() as readonly QueryObserverResult[]) as Signal<
44
+ QueryObserverResult[]
45
+ >
43
46
 
44
- const unsub = observer.subscribe(
45
- (results: readonly QueryObserverResult[]) => {
46
- resultSig.set(results as QueryObserverResult[])
47
- },
48
- )
47
+ const unsub = observer.subscribe((results: readonly QueryObserverResult[]) => {
48
+ resultSig.set(results as QueryObserverResult[])
49
+ })
49
50
 
50
51
  // When signals inside queries() change, update the observer.
51
52
  effect(() => {
@@ -1,6 +1,6 @@
1
- import type { Props, VNode, VNodeChild } from '@pyreon/core'
2
- import { createContext, provide, useContext } from '@pyreon/core'
3
- import { useQueryClient } from './query-client'
1
+ import type { Props, VNode, VNodeChild } from "@pyreon/core"
2
+ import { createContext, provide, useContext } from "@pyreon/core"
3
+ import { useQueryClient } from "./query-client"
4
4
 
5
5
  // ─── Context ────────────────────────────────────────────────────────────────
6
6
 
@@ -8,8 +8,7 @@ interface ErrorResetBoundaryValue {
8
8
  reset: () => void
9
9
  }
10
10
 
11
- const QueryErrorResetBoundaryContext =
12
- createContext<ErrorResetBoundaryValue | null>(null)
11
+ const QueryErrorResetBoundaryContext = createContext<ErrorResetBoundaryValue | null>(null)
13
12
 
14
13
  // ─── QueryErrorResetBoundary ─────────────────────────────────────────────────
15
14
 
@@ -36,16 +35,14 @@ export interface QueryErrorResetBoundaryProps extends Props {
36
35
  * }, h(MyComponent, null)),
37
36
  * )
38
37
  */
39
- export function QueryErrorResetBoundary(
40
- props: QueryErrorResetBoundaryProps,
41
- ): VNode {
38
+ export function QueryErrorResetBoundary(props: QueryErrorResetBoundaryProps): VNode {
42
39
  const client = useQueryClient()
43
40
 
44
41
  const value: ErrorResetBoundaryValue = {
45
42
  reset: () => {
46
43
  // Reset all active queries that are in error state so they refetch.
47
44
  client.refetchQueries({
48
- predicate: (query) => query.state.status === 'error',
45
+ predicate: (query) => query.state.status === "error",
49
46
  })
50
47
  },
51
48
  }
@@ -53,7 +50,7 @@ export function QueryErrorResetBoundary(
53
50
  provide(QueryErrorResetBoundaryContext, value)
54
51
 
55
52
  const ch = props.children
56
- return (typeof ch === 'function' ? (ch as () => VNodeChild)() : ch) as VNode
53
+ return (typeof ch === "function" ? (ch as () => VNodeChild)() : ch) as VNode
57
54
  }
58
55
 
59
56
  // ─── useQueryErrorResetBoundary ──────────────────────────────────────────────
@@ -79,7 +76,7 @@ export function useQueryErrorResetBoundary(): ErrorResetBoundaryValue {
79
76
  return {
80
77
  reset: () => {
81
78
  client.refetchQueries({
82
- predicate: (query) => query.state.status === 'error',
79
+ predicate: (query) => query.state.status === "error",
83
80
  })
84
81
  },
85
82
  }
package/src/use-query.ts CHANGED
@@ -1,21 +1,21 @@
1
- import { onUnmount } from '@pyreon/core'
2
- import type { Signal } from '@pyreon/reactivity'
3
- import { batch, effect, signal } from '@pyreon/reactivity'
1
+ import { onUnmount } from "@pyreon/core"
2
+ import type { Signal } from "@pyreon/reactivity"
3
+ import { batch, effect, signal } from "@pyreon/reactivity"
4
4
  import type {
5
5
  DefaultError,
6
6
  QueryKey,
7
7
  QueryObserverOptions,
8
8
  QueryObserverResult,
9
- } from '@tanstack/query-core'
10
- import { QueryObserver } from '@tanstack/query-core'
11
- import { useQueryClient } from './query-client'
9
+ } from "@tanstack/query-core"
10
+ import { QueryObserver } from "@tanstack/query-core"
11
+ import { useQueryClient } from "./query-client"
12
12
 
13
13
  export interface UseQueryResult<TData, TError = DefaultError> {
14
14
  /** Raw signal — the full observer result. Fine-grained accessors below are preferred. */
15
15
  result: Signal<QueryObserverResult<TData, TError>>
16
16
  data: Signal<TData | undefined>
17
17
  error: Signal<TError | null>
18
- status: Signal<'pending' | 'error' | 'success'>
18
+ status: Signal<"pending" | "error" | "success">
19
19
  isPending: Signal<boolean>
20
20
  isLoading: Signal<boolean>
21
21
  isFetching: Signal<boolean>
@@ -40,18 +40,11 @@ export interface UseQueryResult<TData, TError = DefaultError> {
40
40
  * }))
41
41
  * // In template: () => query.data()?.name
42
42
  */
43
- export function useQuery<
44
- TData = unknown,
45
- TError = DefaultError,
46
- TKey extends QueryKey = QueryKey,
47
- >(
43
+ export function useQuery<TData = unknown, TError = DefaultError, TKey extends QueryKey = QueryKey>(
48
44
  options: () => QueryObserverOptions<TData, TError, TData, TData, TKey>,
49
45
  ): UseQueryResult<TData, TError> {
50
46
  const client = useQueryClient()
51
- const observer = new QueryObserver<TData, TError, TData, TData, TKey>(
52
- client,
53
- options(),
54
- )
47
+ const observer = new QueryObserver<TData, TError, TData, TData, TKey>(client, options())
55
48
  const initial = observer.getCurrentResult()
56
49
 
57
50
  // Fine-grained signals: each field is independent so only effects that read
@@ -59,7 +52,7 @@ export function useQuery<
59
52
  const resultSig = signal<QueryObserverResult<TData, TError>>(initial)
60
53
  const dataSig = signal<TData | undefined>(initial.data)
61
54
  const errorSig = signal<TError | null>(initial.error ?? null)
62
- const statusSig = signal<'pending' | 'error' | 'success'>(initial.status)
55
+ const statusSig = signal<"pending" | "error" | "success">(initial.status)
63
56
  const isPending = signal(initial.isPending)
64
57
  const isLoading = signal(initial.isLoading)
65
58
  const isFetching = signal(initial.isFetching)
package/src/use-sse.ts ADDED
@@ -0,0 +1,266 @@
1
+ import { onUnmount } from "@pyreon/core"
2
+ import type { Signal } from "@pyreon/reactivity"
3
+ import { batch, effect, signal } from "@pyreon/reactivity"
4
+ import type { QueryClient } from "@tanstack/query-core"
5
+ import { useQueryClient } from "./query-client"
6
+
7
+ // ─── Types ───────────────────────────────────────────────────────────────────
8
+
9
+ export type SSEStatus = "connecting" | "connected" | "disconnected" | "error"
10
+
11
+ export interface UseSSEOptions<T = string> {
12
+ /** EventSource URL — can be a signal for reactive URLs */
13
+ url: string | (() => string)
14
+ /** Named event type(s) to listen for — if omitted, listens to generic `message` events */
15
+ events?: string | string[]
16
+ /** Parse raw event data — e.g. `JSON.parse` for automatic deserialization */
17
+ parse?: (raw: string) => T
18
+ /** Whether the SSE connection is enabled — default: true */
19
+ enabled?: boolean | (() => boolean)
20
+ /** Whether to automatically reconnect — default: true */
21
+ reconnect?: boolean
22
+ /** Initial reconnect delay in ms — doubles on each retry, default: 1000 */
23
+ reconnectDelay?: number
24
+ /** Maximum reconnect attempts — default: 10, 0 = unlimited */
25
+ maxReconnectAttempts?: number
26
+ /** Whether to send cookies with the request — default: false */
27
+ withCredentials?: boolean
28
+ /** Called when a message is received — use queryClient to invalidate or update cache */
29
+ onMessage?: (data: T, queryClient: QueryClient) => void
30
+ /** Called when the EventSource connection opens */
31
+ onOpen?: (event: Event) => void
32
+ /** Called when a connection error occurs */
33
+ onError?: (event: Event) => void
34
+ }
35
+
36
+ export interface UseSSEResult<T> {
37
+ /** Last received message data */
38
+ data: Signal<T | null>
39
+ /** Current connection status */
40
+ status: Signal<SSEStatus>
41
+ /** Last error event */
42
+ error: Signal<Event | null>
43
+ /** Last `id` field received from the server (per SSE spec) */
44
+ lastEventId: () => string
45
+ /** EventSource readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED */
46
+ readyState: () => number
47
+ /** Manually close the connection */
48
+ close: () => void
49
+ /** Manually reconnect */
50
+ reconnect: () => void
51
+ }
52
+
53
+ // ─── useSSE ─────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Reactive Server-Sent Events hook that integrates with TanStack Query.
57
+ * Automatically manages connection lifecycle, reconnection, and cleanup.
58
+ *
59
+ * Use the `onMessage` callback to invalidate or update query cache
60
+ * when the server pushes data.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const sse = useSSE({
65
+ * url: '/api/events',
66
+ * parse: JSON.parse,
67
+ * onMessage: (data, queryClient) => {
68
+ * if (data.type === 'order-updated') {
69
+ * queryClient.invalidateQueries({ queryKey: ['orders'] })
70
+ * }
71
+ * },
72
+ * })
73
+ * // sse.data() — last received message (parsed)
74
+ * // sse.status() — 'connecting' | 'connected' | 'disconnected' | 'error'
75
+ * // sse.error() — last error event or null
76
+ * ```
77
+ */
78
+ export function useSSE<T = string>(options: UseSSEOptions<T>): UseSSEResult<T> {
79
+ const queryClient = useQueryClient()
80
+ const data = signal<T | null>(null)
81
+ const status = signal<SSEStatus>("disconnected")
82
+ const error = signal<Event | null>(null)
83
+ const lastEventId = signal("")
84
+ const readyState = signal<number>(2) // Start as CLOSED until connected
85
+
86
+ let es: EventSource | null = null
87
+ let reconnectAttempts = 0
88
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
89
+ let intentionalClose = false
90
+
91
+ const reconnectEnabled = options.reconnect !== false
92
+ const baseDelay = options.reconnectDelay ?? 1000
93
+ const maxAttempts = options.maxReconnectAttempts ?? 10
94
+ const eventNames = options.events
95
+ ? Array.isArray(options.events)
96
+ ? options.events
97
+ : [options.events]
98
+ : null
99
+
100
+ function getUrl(): string {
101
+ return typeof options.url === "function" ? options.url() : options.url
102
+ }
103
+
104
+ function isEnabled(): boolean {
105
+ if (options.enabled === undefined) return true
106
+ return typeof options.enabled === "function" ? options.enabled() : options.enabled
107
+ }
108
+
109
+ function handleMessage(event: MessageEvent): void {
110
+ try {
111
+ // Track lastEventId from the SSE spec
112
+ if (event.lastEventId !== undefined && event.lastEventId !== "") {
113
+ lastEventId.set(event.lastEventId)
114
+ }
115
+ const parsed = options.parse ? options.parse(event.data as string) : (event.data as T)
116
+ batch(() => {
117
+ data.set(parsed)
118
+ error.set(null)
119
+ })
120
+ options.onMessage?.(parsed, queryClient)
121
+ } catch {
122
+ // Message handler errors should not crash the subscription
123
+ }
124
+ }
125
+
126
+ function attachListeners(source: EventSource): void {
127
+ if (eventNames) {
128
+ for (const name of eventNames) {
129
+ source.addEventListener(name, handleMessage as EventListener)
130
+ }
131
+ } else {
132
+ source.onmessage = handleMessage
133
+ }
134
+ }
135
+
136
+ function removeListeners(source: EventSource): void {
137
+ source.onopen = null
138
+ source.onmessage = null
139
+ source.onerror = null
140
+
141
+ if (eventNames) {
142
+ for (const name of eventNames) {
143
+ source.removeEventListener(name, handleMessage as EventListener)
144
+ }
145
+ }
146
+ }
147
+
148
+ function handleError(event: Event): void {
149
+ status.set("error")
150
+ error.set(event)
151
+ readyState.set(es?.readyState ?? EventSource.CLOSED)
152
+ options.onError?.(event)
153
+
154
+ // EventSource auto-reconnects for transient errors, but if readyState is CLOSED
155
+ // the browser has given up and we need to handle reconnection ourselves
156
+ if (es?.readyState === EventSource.CLOSED) {
157
+ removeListeners(es)
158
+ es.close()
159
+ es = null
160
+ if (!intentionalClose && reconnectEnabled) {
161
+ scheduleReconnect()
162
+ }
163
+ }
164
+ }
165
+
166
+ function connect(): void {
167
+ // Clean up existing connection
168
+ if (es) {
169
+ removeListeners(es)
170
+ es.close()
171
+ es = null
172
+ }
173
+
174
+ if (!isEnabled()) {
175
+ status.set("disconnected")
176
+ return
177
+ }
178
+
179
+ status.set("connecting")
180
+
181
+ try {
182
+ es = new EventSource(getUrl(), {
183
+ withCredentials: options.withCredentials ?? false,
184
+ })
185
+ readyState.set(EventSource.CONNECTING)
186
+ } catch {
187
+ status.set("error")
188
+ readyState.set(EventSource.CLOSED)
189
+ scheduleReconnect()
190
+ return
191
+ }
192
+
193
+ es.onopen = (event: Event) => {
194
+ batch(() => {
195
+ status.set("connected")
196
+ error.set(null)
197
+ readyState.set(EventSource.OPEN)
198
+ reconnectAttempts = 0
199
+ })
200
+ options.onOpen?.(event)
201
+ }
202
+
203
+ attachListeners(es)
204
+ es.onerror = handleError
205
+ }
206
+
207
+ function scheduleReconnect(): void {
208
+ if (!reconnectEnabled) return
209
+ if (maxAttempts > 0 && reconnectAttempts >= maxAttempts) return
210
+
211
+ const delay = baseDelay * 2 ** reconnectAttempts
212
+ reconnectAttempts++
213
+
214
+ reconnectTimer = setTimeout(() => {
215
+ reconnectTimer = null
216
+ if (!intentionalClose && isEnabled()) {
217
+ connect()
218
+ }
219
+ }, delay)
220
+ }
221
+
222
+ function close(): void {
223
+ intentionalClose = true
224
+ if (reconnectTimer !== null) {
225
+ clearTimeout(reconnectTimer)
226
+ reconnectTimer = null
227
+ }
228
+ if (es) {
229
+ removeListeners(es)
230
+ es.close()
231
+ es = null
232
+ }
233
+ status.set("disconnected")
234
+ readyState.set(EventSource.CLOSED)
235
+ }
236
+
237
+ function manualReconnect(): void {
238
+ intentionalClose = false
239
+ reconnectAttempts = 0
240
+ connect()
241
+ }
242
+
243
+ // Track reactive URL and enabled state
244
+ effect(() => {
245
+ // Read reactive values to subscribe to changes
246
+ if (typeof options.url === "function") options.url()
247
+ if (typeof options.enabled === "function") options.enabled()
248
+
249
+ intentionalClose = false
250
+ reconnectAttempts = 0
251
+ connect()
252
+ })
253
+
254
+ // Cleanup on unmount
255
+ onUnmount(() => close())
256
+
257
+ return {
258
+ data,
259
+ status,
260
+ error,
261
+ lastEventId: () => lastEventId(),
262
+ readyState: () => readyState(),
263
+ close,
264
+ reconnect: manualReconnect,
265
+ }
266
+ }
@@ -1,16 +1,12 @@
1
- import { onUnmount } from '@pyreon/core'
2
- import type { Signal } from '@pyreon/reactivity'
3
- import { batch, effect, signal } from '@pyreon/reactivity'
4
- import type { QueryClient } from '@tanstack/query-core'
5
- import { useQueryClient } from './query-client'
1
+ import { onUnmount } from "@pyreon/core"
2
+ import type { Signal } from "@pyreon/reactivity"
3
+ import { batch, effect, signal } from "@pyreon/reactivity"
4
+ import type { QueryClient } from "@tanstack/query-core"
5
+ import { useQueryClient } from "./query-client"
6
6
 
7
7
  // ─── Types ───────────────────────────────────────────────────────────────────
8
8
 
9
- export type SubscriptionStatus =
10
- | 'connecting'
11
- | 'connected'
12
- | 'disconnected'
13
- | 'error'
9
+ export type SubscriptionStatus = "connecting" | "connected" | "disconnected" | "error"
14
10
 
15
11
  export interface UseSubscriptionOptions {
16
12
  /** WebSocket URL — can be a signal for reactive URLs */
@@ -70,11 +66,9 @@ export interface UseSubscriptionResult {
70
66
  * // sub.send(JSON.stringify({ type: 'subscribe', channel: 'orders' }))
71
67
  * ```
72
68
  */
73
- export function useSubscription(
74
- options: UseSubscriptionOptions,
75
- ): UseSubscriptionResult {
69
+ export function useSubscription(options: UseSubscriptionOptions): UseSubscriptionResult {
76
70
  const queryClient = useQueryClient()
77
- const status = signal<SubscriptionStatus>('disconnected')
71
+ const status = signal<SubscriptionStatus>("disconnected")
78
72
 
79
73
  let ws: WebSocket | null = null
80
74
  let reconnectAttempts = 0
@@ -86,14 +80,12 @@ export function useSubscription(
86
80
  const maxAttempts = options.maxReconnectAttempts ?? 10
87
81
 
88
82
  function getUrl(): string {
89
- return typeof options.url === 'function' ? options.url() : options.url
83
+ return typeof options.url === "function" ? options.url() : options.url
90
84
  }
91
85
 
92
86
  function isEnabled(): boolean {
93
87
  if (options.enabled === undefined) return true
94
- return typeof options.enabled === 'function'
95
- ? options.enabled()
96
- : options.enabled
88
+ return typeof options.enabled === "function" ? options.enabled() : options.enabled
97
89
  }
98
90
 
99
91
  function connect(): void {
@@ -102,45 +94,44 @@ export function useSubscription(
102
94
  ws.onmessage = null
103
95
  ws.onclose = null
104
96
  ws.onerror = null
105
- if (
106
- ws.readyState === WebSocket.OPEN ||
107
- ws.readyState === WebSocket.CONNECTING
108
- ) {
97
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
109
98
  ws.close()
110
99
  }
111
100
  }
112
101
 
113
102
  if (!isEnabled()) {
114
- status.set('disconnected')
103
+ status.set("disconnected")
115
104
  return
116
105
  }
117
106
 
118
- status.set('connecting')
107
+ status.set("connecting")
119
108
 
120
109
  try {
121
- ws = options.protocols
122
- ? new WebSocket(getUrl(), options.protocols)
123
- : new WebSocket(getUrl())
110
+ ws = options.protocols ? new WebSocket(getUrl(), options.protocols) : new WebSocket(getUrl())
124
111
  } catch {
125
- status.set('error')
112
+ status.set("error")
126
113
  scheduleReconnect()
127
114
  return
128
115
  }
129
116
 
130
117
  ws.onopen = (event) => {
131
118
  batch(() => {
132
- status.set('connected')
119
+ status.set("connected")
133
120
  reconnectAttempts = 0
134
121
  })
135
122
  options.onOpen?.(event)
136
123
  }
137
124
 
138
125
  ws.onmessage = (event) => {
139
- options.onMessage(event, queryClient)
126
+ try {
127
+ options.onMessage(event, queryClient)
128
+ } catch {
129
+ // Message handler errors should not crash the subscription
130
+ }
140
131
  }
141
132
 
142
133
  ws.onclose = (event) => {
143
- status.set('disconnected')
134
+ status.set("disconnected")
144
135
  options.onClose?.(event)
145
136
 
146
137
  if (!intentionalClose && reconnectEnabled) {
@@ -149,7 +140,7 @@ export function useSubscription(
149
140
  }
150
141
 
151
142
  ws.onerror = (event) => {
152
- status.set('error')
143
+ status.set("error")
153
144
  options.onError?.(event)
154
145
  }
155
146
  }
@@ -186,15 +177,12 @@ export function useSubscription(
186
177
  ws.onmessage = null
187
178
  ws.onclose = null
188
179
  ws.onerror = null
189
- if (
190
- ws.readyState === WebSocket.OPEN ||
191
- ws.readyState === WebSocket.CONNECTING
192
- ) {
180
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
193
181
  ws.close()
194
182
  }
195
183
  ws = null
196
184
  }
197
- status.set('disconnected')
185
+ status.set("disconnected")
198
186
  }
199
187
 
200
188
  function manualReconnect(): void {
@@ -206,8 +194,8 @@ export function useSubscription(
206
194
  // Track reactive URL and enabled state
207
195
  effect(() => {
208
196
  // Read reactive values to subscribe to changes
209
- if (typeof options.url === 'function') options.url()
210
- if (typeof options.enabled === 'function') options.enabled()
197
+ if (typeof options.url === "function") options.url()
198
+ if (typeof options.enabled === "function") options.enabled()
211
199
 
212
200
  intentionalClose = false
213
201
  reconnectAttempts = 0