@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,360 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { vi, describe, test, expect, it } from "vitest";
3
+
4
+ import { useSubscriptionCore } from "./useSubscriptionCore";
5
+
6
+ const message = "test";
7
+
8
+ describe("useSubscriptionCore", () => {
9
+ function getFns() {
10
+ const cleanupFn = vi.fn();
11
+ const subscriberFn = vi.fn(() => cleanupFn);
12
+ const handler = vi.fn();
13
+
14
+ return {
15
+ cleanupFn,
16
+ subscriberFn,
17
+ handler,
18
+ };
19
+ }
20
+
21
+ test.concurrent("should call subscriberFn by on mount by default", () => {
22
+ const { subscriberFn } = getFns();
23
+
24
+ renderHook(() => useSubscriptionCore({ subscriberFn }));
25
+
26
+ expect(subscriberFn).toBeCalledTimes(1);
27
+ });
28
+ test.concurrent(
29
+ "should not call subscriberFn by on mount if enabled is false",
30
+ () => {
31
+ const { subscriberFn } = getFns();
32
+
33
+ renderHook(() =>
34
+ useSubscriptionCore({
35
+ subscriberFn,
36
+ enabled: false,
37
+ })
38
+ );
39
+
40
+ expect(subscriberFn).toBeCalledTimes(0);
41
+ }
42
+ );
43
+ test.concurrent("status should be in loading by default", () => {
44
+ const { subscriberFn } = getFns();
45
+ const { result } = renderHook(() => useSubscriptionCore({ subscriberFn }));
46
+
47
+ expect(result.current.status).toBe("subscribing");
48
+ });
49
+ test.concurrent("status should be in idle if enabled is false", () => {
50
+ const { subscriberFn } = getFns();
51
+ const { result } = renderHook(() =>
52
+ useSubscriptionCore({ subscriberFn, enabled: false })
53
+ );
54
+
55
+ expect(result.current.status).toBe("idle");
56
+ });
57
+ it.concurrent("should call cleanupFn by on unmount", () => {
58
+ const { subscriberFn, cleanupFn } = getFns();
59
+ const { unmount } = renderHook(() => useSubscriptionCore({ subscriberFn }));
60
+
61
+ unmount();
62
+
63
+ expect(cleanupFn).toBeCalledTimes(1);
64
+ });
65
+ it.concurrent(
66
+ "should not call cleanupFn by on unmount if enabled is false",
67
+ () => {
68
+ const { subscriberFn, cleanupFn } = getFns();
69
+ const { unmount } = renderHook(() =>
70
+ useSubscriptionCore({ subscriberFn, enabled: false })
71
+ );
72
+
73
+ unmount();
74
+
75
+ expect(cleanupFn).toBeCalledTimes(0);
76
+ }
77
+ );
78
+ it.concurrent(
79
+ "should call cleanupFn if enabled is false and explicit subscribe",
80
+ () => {
81
+ const { subscriberFn, cleanupFn } = getFns();
82
+ const { result, unmount } = renderHook(() =>
83
+ useSubscriptionCore({ subscriberFn, enabled: false })
84
+ );
85
+
86
+ act(() => result.current.reSubscribe());
87
+
88
+ unmount();
89
+
90
+ expect(cleanupFn).toBeCalledTimes(1);
91
+ }
92
+ );
93
+ test.concurrent(
94
+ "status is set to subscribed whien connection is acknowledged manually",
95
+ () => {
96
+ const { cleanupFn } = getFns();
97
+ const { result } = renderHook(() =>
98
+ useSubscriptionCore({
99
+ subscriberFn: (_, con) => {
100
+ con.acknowledge();
101
+
102
+ return cleanupFn;
103
+ },
104
+ })
105
+ );
106
+
107
+ act(() => result.current.reSubscribe());
108
+
109
+ expect(result.current.status).toBe("subscribed");
110
+ }
111
+ );
112
+ test.concurrent(
113
+ "status is set to subscribed whien connection is acknowledged by handler",
114
+ () => {
115
+ const { cleanupFn } = getFns();
116
+ const { result } = renderHook(() =>
117
+ useSubscriptionCore({
118
+ subscriberFn: (handler) => {
119
+ handler(message);
120
+
121
+ return cleanupFn;
122
+ },
123
+ })
124
+ );
125
+
126
+ act(() => result.current.reSubscribe());
127
+
128
+ expect(result.current.status).toBe("subscribed");
129
+ }
130
+ );
131
+ it.concurrent("should call onSubscribed if provided", () => {
132
+ const { cleanupFn } = getFns();
133
+ const onSubscribed = vi.fn();
134
+
135
+ renderHook(() =>
136
+ useSubscriptionCore({
137
+ subscriberFn: (handler) => {
138
+ handler(message);
139
+
140
+ return cleanupFn;
141
+ },
142
+ onSubscribed,
143
+ })
144
+ );
145
+
146
+ expect(onSubscribed).toBeCalledTimes(1);
147
+ });
148
+ it.concurrent("should call onMessage if provided", () => {
149
+ const { cleanupFn } = getFns();
150
+ const onMessage = vi.fn();
151
+
152
+ renderHook(() =>
153
+ useSubscriptionCore({
154
+ subscriberFn: (handler) => {
155
+ handler(message);
156
+
157
+ return cleanupFn;
158
+ },
159
+ onMessage,
160
+ })
161
+ );
162
+
163
+ expect(onMessage).toBeCalledTimes(1);
164
+ });
165
+ it.concurrent("should call compareOrThrow if provided", () => {
166
+ const { cleanupFn } = getFns();
167
+ const compareOrThrow = vi.fn();
168
+
169
+ renderHook(() =>
170
+ useSubscriptionCore({
171
+ subscriberFn: (handler) => {
172
+ handler(message);
173
+
174
+ return cleanupFn;
175
+ },
176
+ compareOrThrow,
177
+ })
178
+ );
179
+
180
+ expect(compareOrThrow).toBeCalledTimes(1);
181
+ });
182
+ it.concurrent("should call onError if provided", () => {
183
+ const { cleanupFn } = getFns();
184
+ const onError = vi.fn();
185
+
186
+ renderHook(() =>
187
+ useSubscriptionCore({
188
+ subscriberFn: (handler) => {
189
+ handler(message);
190
+
191
+ return cleanupFn;
192
+ },
193
+ onError,
194
+ compareOrThrow: () => true,
195
+ })
196
+ );
197
+
198
+ expect(onError).toBeCalledTimes(1);
199
+ });
200
+ it.concurrent(
201
+ "should not call onMessage if provided and compareOrThrow is true",
202
+ () => {
203
+ const { cleanupFn } = getFns();
204
+ const onMessage = vi.fn();
205
+
206
+ renderHook(() =>
207
+ useSubscriptionCore({
208
+ subscriberFn: (handler) => {
209
+ handler(message);
210
+
211
+ return cleanupFn;
212
+ },
213
+ onMessage,
214
+ compareOrThrow: () => true,
215
+ })
216
+ );
217
+
218
+ expect(onMessage).toBeCalledTimes(0);
219
+ }
220
+ );
221
+ it.concurrent(
222
+ "should call onMessage if provided and compareOrThrow is false",
223
+ () => {
224
+ const { cleanupFn } = getFns();
225
+ const onMessage = vi.fn();
226
+
227
+ renderHook(() =>
228
+ useSubscriptionCore({
229
+ subscriberFn: (handler) => {
230
+ handler(message);
231
+
232
+ return cleanupFn;
233
+ },
234
+ onMessage,
235
+ compareOrThrow: () => false,
236
+ })
237
+ );
238
+
239
+ expect(onMessage).toBeCalledTimes(1);
240
+ }
241
+ );
242
+ it.concurrent(
243
+ "should call onError if provided and compareOrThrow is false",
244
+ () => {
245
+ const { cleanupFn } = getFns();
246
+ const onError = vi.fn();
247
+
248
+ renderHook(() =>
249
+ useSubscriptionCore({
250
+ subscriberFn: (handler) => {
251
+ handler(message);
252
+
253
+ return cleanupFn;
254
+ },
255
+ onError,
256
+ compareOrThrow: () => true,
257
+ })
258
+ );
259
+
260
+ expect(onError).toBeCalledTimes(1);
261
+ }
262
+ );
263
+ it.concurrent("should set initialData if provided", () => {
264
+ const { cleanupFn } = getFns();
265
+ const { result } = renderHook(() =>
266
+ useSubscriptionCore<string>({
267
+ subscriberFn: (handler) => {
268
+ handler(message);
269
+
270
+ return cleanupFn;
271
+ },
272
+ initialState: "test",
273
+ })
274
+ );
275
+
276
+ expect(result.current.data).toBe("test");
277
+ });
278
+ it.concurrent("should call select function if provided", () => {
279
+ const { cleanupFn } = getFns();
280
+ const select = vi.fn();
281
+
282
+ renderHook(() =>
283
+ useSubscriptionCore({
284
+ subscriberFn: (handler) => {
285
+ handler(message);
286
+
287
+ return cleanupFn;
288
+ },
289
+ select,
290
+ })
291
+ );
292
+
293
+ expect(select).toBeCalled();
294
+ });
295
+ it.concurrent(
296
+ "should return correct data if select function is provided",
297
+ () => {
298
+ const { cleanupFn } = getFns();
299
+
300
+ const { result } = renderHook(() =>
301
+ useSubscriptionCore<unknown, { foo: string }>({
302
+ subscriberFn: (handler) => {
303
+ handler({
304
+ foo: "test",
305
+ });
306
+
307
+ return cleanupFn;
308
+ },
309
+ select: (data) => data.foo,
310
+ })
311
+ );
312
+
313
+ expect(result.current.data).toBe(message);
314
+ }
315
+ );
316
+ it.concurrent(
317
+ "should set the state to the message passed to handler function",
318
+ () => {
319
+ const { cleanupFn } = getFns();
320
+
321
+ const { result } = renderHook(() =>
322
+ useSubscriptionCore({
323
+ subscriberFn: (handler) => {
324
+ handler(message);
325
+
326
+ return cleanupFn;
327
+ },
328
+ })
329
+ );
330
+
331
+ expect(result.current.data).toBe(message);
332
+ }
333
+ );
334
+ it.concurrent(
335
+ "should not update status and data when stateless is `true`",
336
+ () => {
337
+ const { cleanupFn } = getFns();
338
+
339
+ const { result } = renderHook(() =>
340
+ useSubscriptionCore({
341
+ subscriberFn: (handler) => {
342
+ handler({
343
+ hello: "world",
344
+ });
345
+
346
+ return cleanupFn;
347
+ },
348
+ stateless: true,
349
+ })
350
+ );
351
+
352
+ // @ts-ignore ts should kick in and prevent the
353
+ // following action but okay for testing as those values will always exist in returned object
354
+ const { data, status } = result.current;
355
+
356
+ expect(data).toBe(undefined);
357
+ expect(status).toBe("idle"); // default
358
+ }
359
+ );
360
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * @fileoverview useSubscriptionCore
3
+ * @name useSubscriptionCore.ts
4
+ * @description useSubscriptionCore | This hook handle core subscription logic
5
+ * and provides a way to subscribe and unsubscribe to a topic via any callback
6
+ */
7
+
8
+ import { shallowEqual } from "@pixotope/utils/comparison";
9
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
+
11
+ type SubscriptionState = "idle" | "subscribed" | "subscribing";
12
+ export type MessageHandler<TMessage> = (message: TMessage) => void;
13
+ export type Connection = {
14
+ acknowledge: () => void;
15
+ };
16
+ export type SubscriptionCoreOptions<
17
+ TData,
18
+ TMessage = TData,
19
+ Stateless extends true | undefined = undefined,
20
+ > = {
21
+ /**
22
+ * @description Function that will be called when the message is received.
23
+ */
24
+ onMessage?: (message: TMessage) => void;
25
+ /**
26
+ * @description Function that will be called when the subscription is created.
27
+ */
28
+ onSubscribed?: (message: TMessage) => void;
29
+ /**
30
+ * @description Function that will be called when the subscription is created.
31
+ */
32
+ onError?: (error: TMessage) => void;
33
+ /**
34
+ * @description Whether the subscription is enabled.
35
+ * @default true
36
+ */
37
+ enabled?: boolean;
38
+ /**
39
+ * @description Function that will be called to compare the message with the
40
+ * previous message. If the function returns true, the message will be ignored.
41
+ */
42
+ compareOrThrow?: (data: TMessage) => boolean;
43
+ /**
44
+ * @description Function that will be called to select the data from the message.
45
+ */
46
+ select?: (data: TMessage) => TData;
47
+ /**
48
+ * @description Initial state of the subscription.
49
+ * @default undefined
50
+ */
51
+ initialState?: TData;
52
+ /**
53
+ * @description Function that will be called when the subscription is created.
54
+ */
55
+ subscriberFn: (
56
+ handler: MessageHandler<TMessage>,
57
+ connection: Connection
58
+ ) => () => void /** unsubscribe Fn */;
59
+ /**
60
+ * @description Whether the subscription is stateless. Data will not be
61
+ * stored by the hook internally.
62
+ * @default false
63
+ */
64
+ stateless?: Stateless;
65
+ /**
66
+ * @description Custom equality function for comparing data.
67
+ * @default shallowEqual
68
+ */
69
+ isEqual?: <T = TData>(a: T, b: T) => boolean;
70
+ };
71
+
72
+ type WithStatus = {
73
+ status: SubscriptionState;
74
+ };
75
+
76
+ type WithData<TData> = {
77
+ data: TData;
78
+ };
79
+
80
+ export type SubscriptionCoreReturnType<
81
+ TData,
82
+ Stateless extends true | undefined,
83
+ > = {
84
+ reSubscribe: () => void;
85
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
86
+ } & (Stateless extends true ? {} : WithStatus & WithData<TData>);
87
+
88
+ /**
89
+ * @description `useSubscriptionCore` | This hook handle core subscription logic
90
+ * and provides a way to subscribe and unsubscribe to a topic via any callback.
91
+ * The hook expose a handler function. All the consumer code need to do is to
92
+ * call this handler function with the message received from the topic or pass
93
+ * it directly to the callback function that is passed to the subscriberFn.
94
+ * - `status` - current status of the subscription
95
+ * - `data` - the data after the select function is applied to the message
96
+ * - `reSubscribe` - function that can be called to re-subscribe to the topic
97
+ *
98
+ * An example how this hook works can be found in {@link ./useSubscription.ts}
99
+ */
100
+ export function useSubscriptionCore<
101
+ TData = unknown,
102
+ TMessage = TData,
103
+ Stateless extends true | undefined = undefined,
104
+ >(
105
+ options: SubscriptionCoreOptions<TData, TMessage, Stateless>
106
+ ): SubscriptionCoreReturnType<TData, Stateless> {
107
+ const {
108
+ enabled = true,
109
+ initialState = undefined,
110
+ onSubscribed,
111
+ stateless,
112
+ compareOrThrow,
113
+ subscriberFn,
114
+ onMessage,
115
+ onError,
116
+ select,
117
+ isEqual = shallowEqual,
118
+ } = options;
119
+ const [status, setStatus] = useState<SubscriptionState>("idle");
120
+ const [data, setData] = useState<TData | undefined>(initialState);
121
+ const unsubcribeRef = useRef<() => void | undefined>(undefined);
122
+ const initialStateRef = useRef(initialState);
123
+ const subscriberFnRef = useRef(subscriberFn);
124
+ const callbackRefRegistry = <const>{
125
+ onMessage,
126
+ onError,
127
+ select,
128
+ compareOrThrow,
129
+ onSubscribed,
130
+ isEqual,
131
+ };
132
+ const internalCallbacksRef = useRef(callbackRefRegistry);
133
+ internalCallbacksRef.current = callbackRefRegistry;
134
+
135
+ const updateStatus = useCallback(
136
+ (newStatus: SubscriptionState, message?: TMessage) => {
137
+ if (stateless) {
138
+ return;
139
+ }
140
+
141
+ setStatus((prev) => {
142
+ if (prev === newStatus) {
143
+ return prev;
144
+ }
145
+
146
+ if (newStatus === "subscribed" && message) {
147
+ internalCallbacksRef.current.onSubscribed?.(message);
148
+ }
149
+
150
+ return newStatus;
151
+ });
152
+ },
153
+ [stateless]
154
+ );
155
+
156
+ const connection: Connection = useMemo(
157
+ () => ({
158
+ acknowledge: () => {
159
+ updateStatus("subscribed");
160
+ },
161
+ }),
162
+ [updateStatus]
163
+ );
164
+
165
+ const handler = useCallback(
166
+ async (message: TMessage) => {
167
+ if (internalCallbacksRef.current.compareOrThrow?.(message)) {
168
+ internalCallbacksRef.current.onError?.(message);
169
+
170
+ return;
171
+ }
172
+
173
+ const selectedMessage = <TData>(
174
+ (internalCallbacksRef.current.select?.(message) ?? message)
175
+ );
176
+
177
+ if (!stateless) {
178
+ setData((prev) => {
179
+ if (isEqual(prev, selectedMessage)) {
180
+ return prev;
181
+ }
182
+
183
+ return selectedMessage;
184
+ });
185
+ }
186
+
187
+ updateStatus("subscribed", message);
188
+ internalCallbacksRef.current.onMessage?.(message);
189
+ },
190
+ [stateless, updateStatus]
191
+ );
192
+
193
+ const unsubscribe = useCallback(() => {
194
+ updateStatus("idle");
195
+ unsubcribeRef.current?.();
196
+ unsubcribeRef.current = undefined;
197
+ }, [updateStatus]);
198
+
199
+ const subscribe = useCallback(() => {
200
+ unsubcribeRef.current?.();
201
+ updateStatus("subscribing");
202
+ setData(initialStateRef.current);
203
+ unsubcribeRef.current = subscriberFnRef.current(handler, connection);
204
+ }, [connection, handler, updateStatus]);
205
+
206
+ useEffect(() => {
207
+ if (enabled) {
208
+ subscribe();
209
+ } else {
210
+ unsubscribe();
211
+ }
212
+
213
+ return () => {
214
+ unsubscribe();
215
+ };
216
+ }, [enabled, subscribe, unsubscribe]);
217
+
218
+ return {
219
+ status,
220
+ data,
221
+ reSubscribe: subscribe,
222
+ } as unknown as any;
223
+ }
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom";
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": "./src",
4
+ "rootDir": "./src",
5
+ "target": "es6",
6
+ "module": "es6",
7
+ "lib": ["es6", "dom", "dom.iterable"],
8
+ "moduleResolution": "bundler",
9
+ "allowJs": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "useDefineForClassFields": true,
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "esModuleInterop": true,
18
+ "noEmit": true,
19
+ "noUnusedLocals": false,
20
+ "noUnusedParameters": false,
21
+ "noImplicitReturns": false,
22
+ "forceConsistentCasingInFileNames": true,
23
+ "jsx": "react"
24
+ },
25
+ "include": ["src"],
26
+ "exclude": ["node_modules", "dist"]
27
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "jsdom",
7
+ setupFiles: "./src/vitest-setup.ts",
8
+ testTimeout: 30_000,
9
+ hookTimeout: 30_000,
10
+ css: true,
11
+ include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
12
+ server: {
13
+ deps: {
14
+ inline: [],
15
+ },
16
+ },
17
+ },
18
+ resolve: {
19
+ alias: {},
20
+ },
21
+ });