@pixotope/query 0.0.1

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.
@@ -0,0 +1,282 @@
1
+ import { fireEvent } from "@testing-library/dom";
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import { vi, describe, beforeEach, it, expect, test } from "vitest";
4
+
5
+ import { useQuery } from "./useQuery";
6
+ import { MockUseQueryUtils } from "./mock/useQuery";
7
+ const mockQueryKey = ["dummyQK"];
8
+
9
+ function until(condition: boolean) {
10
+ return new Promise((resolve, reject) => {
11
+ if (condition) {
12
+ resolve(null);
13
+ } else {
14
+ reject();
15
+ }
16
+ });
17
+ }
18
+
19
+ describe("useQuery", () => {
20
+ let defaultQueryMock: MockUseQueryUtils;
21
+
22
+ beforeEach(() => {
23
+ defaultQueryMock = new MockUseQueryUtils({});
24
+ });
25
+
26
+ it("should call queryFn by on mount by default", () => {
27
+ const mockFunction = vi.fn(() => defaultQueryMock.getMockedQueryFn());
28
+ renderHook(() =>
29
+ useQuery({
30
+ queryFn: mockFunction,
31
+ key: mockQueryKey,
32
+ })
33
+ );
34
+
35
+ expect(mockFunction).toBeCalledTimes(1);
36
+ });
37
+ it("should be in loading state on mount", () => {
38
+ const { result } = renderHook(() =>
39
+ useQuery({
40
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
41
+ key: mockQueryKey,
42
+ })
43
+ );
44
+
45
+ expect(result.current.status).toBe("loading");
46
+ });
47
+ it("should be in success state after queryFn resolves", async () => {
48
+ const { result } = renderHook(() =>
49
+ useQuery({
50
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
51
+ key: mockQueryKey,
52
+ })
53
+ );
54
+ act(() => {
55
+ defaultQueryMock.resolve();
56
+ });
57
+ await waitFor(() => until(result.current.status === "success"), {
58
+ timeout: 1000,
59
+ });
60
+ });
61
+ it("should be in error state after queryFn rejects", async () => {
62
+ const { result } = renderHook(() =>
63
+ useQuery({
64
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
65
+ key: mockQueryKey,
66
+ })
67
+ );
68
+ act(() => defaultQueryMock.reject());
69
+ await waitFor(() => until(result.current.status === "error"), {
70
+ timeout: 1000,
71
+ });
72
+ expect(result.current.status).toBe("error");
73
+ });
74
+ it("should be in timeout state after queryFn rejects with timeout", async () => {
75
+ const { result } = renderHook(() =>
76
+ useQuery({
77
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
78
+ key: mockQueryKey,
79
+ timeout: 0.01,
80
+ })
81
+ );
82
+ await waitFor(() => until(result.current.status === "error"), {
83
+ timeout: 1000,
84
+ });
85
+ expect(result.current.status === "error");
86
+ });
87
+ it("should be in success state after queryFn resolves with initialData", async () => {
88
+ const { result } = renderHook(() =>
89
+ useQuery({
90
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
91
+ key: mockQueryKey,
92
+ initialData: "test-test",
93
+ })
94
+ );
95
+ defaultQueryMock.resolve();
96
+ await waitFor(() => until(result.current.status === "success"), {
97
+ timeout: 1000,
98
+ });
99
+ });
100
+ it("should be in idle state if enabled is false", async () => {
101
+ const { result } = renderHook(() =>
102
+ useQuery({
103
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
104
+ key: mockQueryKey,
105
+ enabled: false,
106
+ })
107
+ );
108
+ expect(result.current.status).toBe("idle");
109
+ });
110
+ test("refetch should trigger query function", async () => {
111
+ const resolver = vi.fn(() => defaultQueryMock.getMockedQueryFn());
112
+ const { result } = await act(() =>
113
+ renderHook(() =>
114
+ useQuery({
115
+ queryFn: resolver,
116
+ key: mockQueryKey,
117
+ })
118
+ )
119
+ );
120
+ await act(() => defaultQueryMock.resolve());
121
+ await waitFor(() => until(result.current.status === "success"), {
122
+ timeout: 1000,
123
+ });
124
+ await act(() => result.current.refetch());
125
+ expect(resolver).toBeCalledTimes(2);
126
+ });
127
+ test("select should return selected right data", async () => {
128
+ const queryMock = new MockUseQueryUtils({
129
+ foo: "bar",
130
+ baz: "qux",
131
+ });
132
+ const { result } = renderHook(() =>
133
+ useQuery({
134
+ queryFn: () => queryMock.getMockedQueryFn(),
135
+ key: mockQueryKey,
136
+ select: (data) => data?.foo,
137
+ })
138
+ );
139
+ expect(result.current.data).toBeUndefined();
140
+ queryMock.resolve();
141
+ await waitFor(() => until(result.current.data !== undefined), {
142
+ timeout: 1000,
143
+ });
144
+ expect(result.current.data).toBe("bar");
145
+ });
146
+ test("onSuccess should be called after queryFn resolves", async () => {
147
+ const onSuccess = vi.fn();
148
+ renderHook(() =>
149
+ useQuery({
150
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
151
+ key: mockQueryKey,
152
+ onSuccess,
153
+ })
154
+ );
155
+ await act(() => defaultQueryMock.resolve());
156
+ await waitFor(() => {}, {
157
+ timeout: 1000,
158
+ });
159
+ expect(onSuccess).toBeCalledTimes(1);
160
+ });
161
+ test("onError should be called after queryFn rejects", async () => {
162
+ const onError = vi.fn();
163
+ renderHook(() =>
164
+ useQuery({
165
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
166
+ key: mockQueryKey,
167
+ onError,
168
+ })
169
+ );
170
+ await act(() => defaultQueryMock.reject());
171
+ await waitFor(() => {}, {
172
+ timeout: 1000,
173
+ });
174
+ expect(onError).toBeCalledTimes(1);
175
+ });
176
+ test("onSettled should be called after queryFn resolves", async () => {
177
+ const onSettled = vi.fn();
178
+ renderHook(() =>
179
+ useQuery({
180
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
181
+ key: mockQueryKey,
182
+ onSettled,
183
+ })
184
+ );
185
+ await act(() => defaultQueryMock.resolve());
186
+ await waitFor(() => {}, {
187
+ timeout: 1000,
188
+ });
189
+ expect(onSettled).toBeCalledTimes(1);
190
+ });
191
+ test("onSettled should be called after queryFn rejects", async () => {
192
+ const onSettled = vi.fn();
193
+ renderHook(() =>
194
+ useQuery({
195
+ queryFn: () => defaultQueryMock.getMockedQueryFn(),
196
+ key: mockQueryKey,
197
+ onSettled,
198
+ })
199
+ );
200
+ await act(() => defaultQueryMock.reject());
201
+ await waitFor(() => {}, {
202
+ timeout: 1000,
203
+ });
204
+ expect(onSettled).toBeCalledTimes(1);
205
+ });
206
+ test("refetch is triggered on window focus", async () => {
207
+ const resolver = vi.fn(() => defaultQueryMock.getMockedQueryFn());
208
+ const { result } = await act(() =>
209
+ renderHook(() =>
210
+ useQuery({
211
+ queryFn: resolver,
212
+ refetchOnWindowFocus: true,
213
+ key: mockQueryKey,
214
+ })
215
+ )
216
+ );
217
+ await act(() => defaultQueryMock.resolve());
218
+ await waitFor(() => until(result.current.status === "success"), {
219
+ timeout: 1000,
220
+ });
221
+ await act(() => fireEvent(window, new FocusEvent("focus")));
222
+ expect(resolver).toBeCalledTimes(2);
223
+ });
224
+ test("refetch interval should work", async () => {
225
+ const resolver = vi.fn(() => defaultQueryMock.getMockedQueryFn());
226
+ const { result } = renderHook(() =>
227
+ useQuery({
228
+ queryFn: resolver,
229
+ refetchInterval: 10,
230
+ key: mockQueryKey,
231
+ })
232
+ );
233
+ await act(() => defaultQueryMock.resolve());
234
+ await waitFor(() => until(result.current.status === "success"), {
235
+ timeout: 1000,
236
+ });
237
+ waitFor(() => until(resolver.call.length > 1), { timeout: 1000 });
238
+ });
239
+ test("refetch should automatically trigger if queryKey is changed", async () => {
240
+ const resolver = vi.fn(() => defaultQueryMock.getMockedQueryFn());
241
+ const { result, rerender } = await act(() =>
242
+ renderHook(
243
+ (props) =>
244
+ useQuery({
245
+ queryFn: props.resolver,
246
+ key: props.key,
247
+ }),
248
+ {
249
+ initialProps: {
250
+ key: ["dummyQK1"],
251
+ resolver,
252
+ },
253
+ }
254
+ )
255
+ );
256
+ await act(() => defaultQueryMock.resolve());
257
+ await waitFor(() => until(result.current.status === "success"), {
258
+ timeout: 1000,
259
+ });
260
+ rerender({ key: ["dummyQK2"], resolver });
261
+ rerender({ key: ["dummyQK2"], resolver });
262
+ expect(resolver).toBeCalledTimes(2);
263
+ });
264
+ test("stateless mode", async () => {
265
+ const resolver = vi.fn(() => defaultQueryMock.getMockedQueryFn());
266
+ const { result } = renderHook(() =>
267
+ useQuery({
268
+ queryFn: resolver,
269
+ key: mockQueryKey,
270
+ stateless: true,
271
+ })
272
+ );
273
+ await act(() => defaultQueryMock.resolve());
274
+ await waitFor(() => until(result.current.status === "success"), {
275
+ timeout: 1000,
276
+ });
277
+
278
+ // Here ts should kick in and complain about but for testing purposes, all good
279
+ // @ts-ignore
280
+ expect(result.current.data).toBeUndefined();
281
+ });
282
+ });
@@ -0,0 +1,302 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export interface QueryOptions<
4
+ TData = unknown,
5
+ TError = unknown,
6
+ TParams = void,
7
+ TSelectData = TData,
8
+ Stateless extends true | undefined = undefined,
9
+ > {
10
+ queryFn: (params: TParams | undefined | void) => Promise<TData>;
11
+ onSuccess?: (data: TData) => Promise<void> | void;
12
+ onError?: (error: TError) => Promise<void> | void;
13
+ onSettled?: (
14
+ data: TData | undefined,
15
+ error: TError | undefined
16
+ ) => Promise<void> | void;
17
+ timeout?: number;
18
+ enabled?: boolean;
19
+ initialData?: TData | Functionize<TData>;
20
+ select?: (data: TData) => TSelectData;
21
+ refetchOnWindowFocus?: boolean;
22
+ refetchInterval?: number;
23
+ compareOrThrow?: (data: TData) => boolean;
24
+ stateless?: Stateless;
25
+ key?: TQueryKey;
26
+ }
27
+
28
+ type Functionize<T> = () => T;
29
+ type QueryStatus = "idle" | "loading" | "success" | "error" | "timeout";
30
+ type RaceErrorReason = "timeout";
31
+ type RacePromises<T> = Promise<T | RaceErrorReason>;
32
+ type WithData<TData> = { data: TData | undefined };
33
+ type TQueryKey = readonly any[];
34
+
35
+ export type UseQueryResult<
36
+ TData = unknown,
37
+ TError = unknown,
38
+ TParams = void,
39
+ TSelectData = TData,
40
+ Stateless extends true | undefined = undefined,
41
+ > = {
42
+ error: TError | null;
43
+ status: QueryStatus;
44
+ refetch: (params: TParams | void | undefined) => Promise<void>;
45
+ reset: () => void;
46
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
47
+ } & (Stateless extends true ? {} : WithData<TSelectData>);
48
+
49
+ function normalizeQueryKey(queryKey: TQueryKey): string {
50
+ return JSON.stringify(queryKey);
51
+ }
52
+
53
+ type TInternalCallbackRef<TData, TError, TSelectData> = {
54
+ onError?: (error: TError) => Promise<void> | void;
55
+ onSuccess?: (data: TData) => Promise<void> | void;
56
+ onSettled?: (
57
+ data: TData | undefined,
58
+ error: TError | undefined
59
+ ) => Promise<void> | void;
60
+ compareOrThrow?: (data: TData) => boolean;
61
+ select?: (data: TData) => TSelectData;
62
+ };
63
+
64
+ type TInternalOptionsRef<TData> = {
65
+ timeout?: number;
66
+ enabled: boolean;
67
+ refetchOnWindowFocus: boolean;
68
+ refetchInterval: number;
69
+ compareOrThrow?: (data: TData) => boolean;
70
+ stateless?: boolean;
71
+ };
72
+
73
+ export const useQuery = <
74
+ TData = unknown,
75
+ TError = unknown,
76
+ TParams = void,
77
+ TSelected = TData,
78
+ Stateless extends true | undefined = undefined,
79
+ >({
80
+ queryFn,
81
+ ...options
82
+ }: QueryOptions<TData, TError, TParams, TSelected, Stateless>): UseQueryResult<
83
+ TSelected,
84
+ TError,
85
+ TParams,
86
+ TSelected,
87
+ Stateless
88
+ > => {
89
+ const {
90
+ enabled = true,
91
+ refetchOnWindowFocus = false,
92
+ refetchInterval = 0,
93
+ initialData,
94
+ select,
95
+ onError,
96
+ onSuccess,
97
+ onSettled,
98
+ timeout,
99
+ compareOrThrow,
100
+ stateless,
101
+ key: queryKeyPassed,
102
+ } = options;
103
+ const queryKey = normalizeQueryKey(queryKeyPassed ?? []);
104
+ const promiseResolutionInProgress = useRef(false);
105
+ const lastFetchedAt = useRef<number | null>(null);
106
+ const lastQueryKey = useRef<string | undefined>(undefined);
107
+ const queryFnRef = useRef(queryFn);
108
+ queryFnRef.current = queryFn;
109
+ const internalCallbackRef = useRef<
110
+ TInternalCallbackRef<TData, TError, TSelected>
111
+ >({});
112
+ internalCallbackRef.current = {
113
+ onError,
114
+ onSuccess,
115
+ onSettled,
116
+ compareOrThrow,
117
+ select,
118
+ };
119
+ const internalOptionsRef = useRef<TInternalOptionsRef<TData>>({
120
+ enabled,
121
+ refetchOnWindowFocus,
122
+ refetchInterval,
123
+ compareOrThrow,
124
+ stateless,
125
+ timeout,
126
+ });
127
+ internalOptionsRef.current = {
128
+ enabled,
129
+ refetchOnWindowFocus,
130
+ refetchInterval,
131
+ compareOrThrow,
132
+ stateless,
133
+ timeout,
134
+ };
135
+
136
+ const [status, setStatus] = useState<QueryStatus>(() => {
137
+ return "idle";
138
+ });
139
+ const [error, setError] = useState<TError | null>(null);
140
+ const [data, setData] = useState<TData | undefined>(() => {
141
+ if (initialData !== undefined) {
142
+ if (typeof initialData === "function") {
143
+ return (initialData as Functionize<TData>)();
144
+ }
145
+
146
+ return initialData as TData;
147
+ }
148
+
149
+ return undefined;
150
+ });
151
+
152
+ const handleSetData = useCallback((data: TData) => {
153
+ if (internalOptionsRef.current.stateless) {
154
+ return;
155
+ }
156
+
157
+ setData(data);
158
+ }, []);
159
+
160
+ const shouldRefetch = useCallback(() => {
161
+ if (!internalOptionsRef.current.enabled) {
162
+ return false;
163
+ }
164
+
165
+ if (lastFetchedAt.current === null) {
166
+ return true;
167
+ }
168
+
169
+ return (
170
+ new Date().getTime() - lastFetchedAt.current >
171
+ internalOptionsRef.current?.refetchInterval
172
+ );
173
+ }, []);
174
+
175
+ const refetch = useCallback(
176
+ async (params: TParams | void | undefined = undefined) => {
177
+ if (
178
+ promiseResolutionInProgress.current ||
179
+ !internalOptionsRef.current.enabled
180
+ ) {
181
+ return;
182
+ }
183
+
184
+ setStatus("loading");
185
+ setError(null);
186
+
187
+ try {
188
+ const promises: RacePromises<TData>[] = [
189
+ queryFnRef.current(params),
190
+ ...(internalOptionsRef.current.timeout
191
+ ? [
192
+ new Promise<RaceErrorReason>((resolve) =>
193
+ setTimeout(
194
+ () => resolve("timeout"),
195
+ internalOptionsRef.current.timeout
196
+ )
197
+ ),
198
+ ]
199
+ : []),
200
+ ];
201
+
202
+ promiseResolutionInProgress.current = true;
203
+ const res = await Promise.race(promises);
204
+ promiseResolutionInProgress.current = false;
205
+
206
+ if (res === "timeout") {
207
+ throw new Error("timeout");
208
+ }
209
+
210
+ handleSetData(res);
211
+
212
+ if (
213
+ internalCallbackRef.current.compareOrThrow &&
214
+ internalCallbackRef.current.compareOrThrow(res) === true
215
+ ) {
216
+ throw new Error("compareOrThrow");
217
+ }
218
+
219
+ setStatus("success");
220
+ lastFetchedAt.current = new Date().getTime();
221
+ internalCallbackRef.current.onSuccess?.(res);
222
+ internalCallbackRef.current.onSettled?.(res, undefined);
223
+ } catch (error: any) {
224
+ setError(error);
225
+ setStatus("error");
226
+ internalCallbackRef.current.onError?.(error);
227
+ internalCallbackRef.current.onSettled?.(undefined, error);
228
+ }
229
+ },
230
+ [handleSetData]
231
+ );
232
+
233
+ const reset = useCallback(() => {
234
+ setData(undefined);
235
+ setStatus("idle");
236
+ setError(null);
237
+ refetch();
238
+ }, [refetch]);
239
+
240
+ useEffect(() => {
241
+ if (enabled) {
242
+ refetch();
243
+ }
244
+ }, [enabled, refetch, queryKey]);
245
+
246
+ useEffect(() => {
247
+ if (lastQueryKey.current === undefined) {
248
+ lastQueryKey.current = queryKey;
249
+ refetch();
250
+ return;
251
+ }
252
+
253
+ if (queryKey !== lastQueryKey.current) {
254
+ lastQueryKey.current = queryKey;
255
+ refetch();
256
+ }
257
+ }, [queryKey, refetch, enabled]);
258
+
259
+ useEffect(() => {
260
+ if (refetchOnWindowFocus) {
261
+ const queryHandleFocus = () => {
262
+ if (shouldRefetch()) {
263
+ refetch();
264
+ }
265
+ };
266
+
267
+ window.addEventListener("focus", queryHandleFocus);
268
+
269
+ return () => {
270
+ window.removeEventListener("focus", queryHandleFocus);
271
+ };
272
+ }
273
+ }, [refetchOnWindowFocus, refetch, shouldRefetch]);
274
+
275
+ useEffect(() => {
276
+ if (refetchInterval !== 0) {
277
+ const interval = setInterval(
278
+ () => {
279
+ if (shouldRefetch()) {
280
+ refetch();
281
+ }
282
+ },
283
+ Math.max(refetchInterval, 1000)
284
+ );
285
+
286
+ return () => {
287
+ clearInterval(interval);
288
+ };
289
+ }
290
+ }, [refetch, refetchInterval, shouldRefetch]);
291
+
292
+ return {
293
+ data:
294
+ select && data !== undefined
295
+ ? select(data as TData)
296
+ : (data as unknown as TSelected),
297
+ error,
298
+ status,
299
+ refetch,
300
+ reset,
301
+ };
302
+ };
@@ -0,0 +1,87 @@
1
+ import type { DHClientWebSocketProxy } from "@pixotope/zmq-client";
2
+ import {
3
+ SubscriptionCoreOptions,
4
+ useSubscriptionCore,
5
+ } from "./useSubscriptionCore";
6
+
7
+ export type SubscriptionOptions<
8
+ TData,
9
+ TMessage = unknown,
10
+ Stateless extends true | undefined = undefined,
11
+ > = Omit<
12
+ SubscriptionCoreOptions<TData, TMessage, Stateless>,
13
+ "subscriberFn"
14
+ > & {
15
+ service: string;
16
+ topic: string;
17
+ maxDownwardDepth?: number;
18
+ };
19
+
20
+ export type TSubscriptionHandlerFunction<TMessage> = (opts: {
21
+ service: string;
22
+ topic: string;
23
+ callback: (message: TMessage) => void;
24
+ maxDownwardDepth?: number;
25
+ }) => () => void;
26
+
27
+ export function useSubscription<
28
+ TData = unknown,
29
+ TMessage = TData,
30
+ Stateless extends true | undefined = undefined,
31
+ >(
32
+ subscriptionHandler: TSubscriptionHandlerFunction<TMessage>,
33
+ options: SubscriptionOptions<TData, TMessage, Stateless>
34
+ ) {
35
+ const { topic, service, maxDownwardDepth = 1000 } = options;
36
+
37
+ return useSubscriptionCore<TData, TMessage, Stateless>({
38
+ ...options,
39
+ subscriberFn: (handler) => {
40
+ const unsubscribe = subscriptionHandler({
41
+ service,
42
+ topic,
43
+ callback: (message: TMessage) => {
44
+ handler(message);
45
+ },
46
+ maxDownwardDepth,
47
+ });
48
+
49
+ return unsubscribe;
50
+ },
51
+ });
52
+ }
53
+
54
+ export function buildServiceSubscriptionHook(
55
+ subscriptionHandler: TSubscriptionHandlerFunction<unknown>,
56
+ targetMachine: (machine: string | undefined) => string
57
+ ) {
58
+ return function useSubscriptionBuilder<
59
+ TData,
60
+ TMessage = TData,
61
+ Stateless extends true | undefined = undefined,
62
+ >(
63
+ options: Omit<
64
+ SubscriptionOptions<TData, TMessage, Stateless>,
65
+ "service"
66
+ > & {
67
+ machine?: string;
68
+ }
69
+ ) {
70
+ const targetService = targetMachine(options.machine);
71
+ return useSubscription<TData, TMessage, Stateless>(
72
+ <TSubscriptionHandlerFunction<TMessage>>subscriptionHandler,
73
+ {
74
+ service: targetService,
75
+ ...options,
76
+ }
77
+ );
78
+ };
79
+ }
80
+
81
+ export function bindClientSocketProxy(
82
+ socketClientProxy: DHClientWebSocketProxy
83
+ ) {
84
+ return (
85
+ ...args: Parameters<Parameters<typeof buildServiceSubscriptionHook>[0]>
86
+ ) => socketClientProxy.mirror(...args)[0];
87
+ }