@pyreon/query 0.10.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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +164 -2
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +74 -7
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +14 -7
- package/src/index.ts +25 -19
- package/src/query-client.ts +5 -5
- package/src/tests/query.test.tsx +254 -268
- package/src/tests/sse.test.tsx +857 -0
- package/src/tests/subscription.test.tsx +200 -82
- package/src/use-infinite-query.ts +11 -19
- package/src/use-is-fetching.ts +5 -5
- package/src/use-mutation.ts +12 -21
- package/src/use-queries.ts +20 -19
- package/src/use-query-error-reset-boundary.ts +8 -11
- package/src/use-query.ts +10 -17
- package/src/use-sse.ts +266 -0
- package/src/use-subscription.ts +27 -39
- package/src/use-suspense-query.ts +18 -34
package/src/use-mutation.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { onUnmount } from
|
|
2
|
-
import type { Signal } from
|
|
3
|
-
import { batch, signal } from
|
|
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
|
|
10
|
-
import { MutationObserver } from
|
|
11
|
-
import { useQueryClient } from
|
|
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<
|
|
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<
|
|
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
|
}
|
package/src/use-queries.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import { onUnmount } from
|
|
2
|
-
import type { Signal } from
|
|
3
|
-
import { effect, signal } from
|
|
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
|
|
10
|
-
import { QueriesObserver } from
|
|
11
|
-
import { useQueryClient } from
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
+
const resultSig = signal(observer.getCurrentResult() as readonly QueryObserverResult[]) as Signal<
|
|
44
|
+
QueryObserverResult[]
|
|
45
|
+
>
|
|
43
46
|
|
|
44
|
-
const unsub = observer.subscribe(
|
|
45
|
-
(results
|
|
46
|
-
|
|
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
|
|
2
|
-
import { createContext, provide, useContext } from
|
|
3
|
-
import { useQueryClient } from
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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
|
|
2
|
-
import type { Signal } from
|
|
3
|
-
import { batch, effect, signal } from
|
|
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
|
|
10
|
-
import { QueryObserver } from
|
|
11
|
-
import { useQueryClient } from
|
|
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<
|
|
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<
|
|
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
|
+
}
|
package/src/use-subscription.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
import { onUnmount } from
|
|
2
|
-
import type { Signal } from
|
|
3
|
-
import { batch, effect, signal } from
|
|
4
|
-
import type { QueryClient } from
|
|
5
|
-
import { useQueryClient } from
|
|
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>(
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
103
|
+
status.set("disconnected")
|
|
115
104
|
return
|
|
116
105
|
}
|
|
117
106
|
|
|
118
|
-
status.set(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
210
|
-
if (typeof 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
|