@spoosh/react 0.1.0-beta.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/dist/index.mjs ADDED
@@ -0,0 +1,555 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/useRead/index.ts
5
+ import {
6
+ useSyncExternalStore,
7
+ useRef,
8
+ useEffect,
9
+ useCallback,
10
+ useId,
11
+ useState
12
+ } from "react";
13
+ import {
14
+ createOperationController,
15
+ createSelectorProxy,
16
+ resolvePath,
17
+ resolveTags
18
+ } from "@spoosh/core";
19
+ function createUseRead(options) {
20
+ const { api, stateManager, eventEmitter, pluginExecutor } = options;
21
+ return function useRead(readFn, readOptions) {
22
+ const {
23
+ enabled = true,
24
+ tags,
25
+ additionalTags,
26
+ ...pluginOpts
27
+ } = readOptions ?? {};
28
+ const hookId = useId();
29
+ const selectorResultRef = useRef({
30
+ call: null,
31
+ selector: null
32
+ });
33
+ const selectorProxy = createSelectorProxy((result2) => {
34
+ selectorResultRef.current = result2;
35
+ });
36
+ readFn(selectorProxy);
37
+ const capturedCall = selectorResultRef.current.call;
38
+ if (!capturedCall) {
39
+ throw new Error(
40
+ "useRead requires calling an HTTP method ($get). Example: useRead((api) => api.posts.$get())"
41
+ );
42
+ }
43
+ const requestOptions = capturedCall.options;
44
+ const resolvedPath = resolvePath(capturedCall.path, requestOptions?.params);
45
+ const resolvedTags = resolveTags({ tags, additionalTags }, resolvedPath);
46
+ const queryKey = stateManager.createQueryKey({
47
+ path: capturedCall.path,
48
+ method: capturedCall.method,
49
+ options: capturedCall.options
50
+ });
51
+ const controllerRef = useRef(null);
52
+ const lifecycleRef = useRef({
53
+ initialized: false,
54
+ prevContext: null
55
+ });
56
+ if (controllerRef.current && controllerRef.current.queryKey !== queryKey) {
57
+ lifecycleRef.current.prevContext = controllerRef.current.controller.getContext();
58
+ }
59
+ if (!controllerRef.current || controllerRef.current.queryKey !== queryKey) {
60
+ const controller2 = createOperationController({
61
+ operationType: "read",
62
+ path: capturedCall.path,
63
+ method: capturedCall.method,
64
+ tags: resolvedTags,
65
+ requestOptions: capturedCall.options,
66
+ stateManager,
67
+ eventEmitter,
68
+ pluginExecutor,
69
+ hookId,
70
+ fetchFn: async (fetchOpts) => {
71
+ let current = api;
72
+ for (const segment of resolvedPath) {
73
+ current = current[segment];
74
+ }
75
+ const method = current[capturedCall.method];
76
+ return method(fetchOpts);
77
+ }
78
+ });
79
+ controllerRef.current = { controller: controller2, queryKey };
80
+ }
81
+ const controller = controllerRef.current.controller;
82
+ controller.setPluginOptions(pluginOpts);
83
+ const state = useSyncExternalStore(
84
+ controller.subscribe,
85
+ controller.getState,
86
+ controller.getState
87
+ );
88
+ const [requestState, setRequestState] = useState({ isPending: false, error: void 0 });
89
+ const abortRef = useRef(controller.abort);
90
+ abortRef.current = controller.abort;
91
+ const pluginOptsKey = JSON.stringify(pluginOpts);
92
+ const executeWithTracking = useCallback(
93
+ async (force = false) => {
94
+ setRequestState((prev) => ({ ...prev, isPending: true }));
95
+ try {
96
+ const response = await controller.execute(void 0, { force });
97
+ if (response.error) {
98
+ setRequestState({ isPending: false, error: response.error });
99
+ } else {
100
+ setRequestState({ isPending: false, error: void 0 });
101
+ }
102
+ return response;
103
+ } catch (err) {
104
+ setRequestState({ isPending: false, error: err });
105
+ throw err;
106
+ }
107
+ },
108
+ [controller]
109
+ );
110
+ useEffect(() => {
111
+ return () => {
112
+ controllerRef.current?.controller.unmount();
113
+ lifecycleRef.current.initialized = false;
114
+ };
115
+ }, []);
116
+ useEffect(() => {
117
+ if (!enabled) return;
118
+ const { initialized, prevContext } = lifecycleRef.current;
119
+ if (!initialized) {
120
+ controller.mount();
121
+ lifecycleRef.current.initialized = true;
122
+ } else if (prevContext) {
123
+ controller.update(prevContext);
124
+ lifecycleRef.current.prevContext = null;
125
+ }
126
+ executeWithTracking(false);
127
+ const unsubRefetch = eventEmitter.on("refetch", (event) => {
128
+ if (event.queryKey === queryKey) {
129
+ executeWithTracking(true);
130
+ }
131
+ });
132
+ const unsubInvalidate = eventEmitter.on(
133
+ "invalidate",
134
+ (invalidatedTags) => {
135
+ const hasMatch = invalidatedTags.some(
136
+ (tag) => resolvedTags.includes(tag)
137
+ );
138
+ if (hasMatch) {
139
+ executeWithTracking(true);
140
+ }
141
+ }
142
+ );
143
+ return () => {
144
+ unsubRefetch();
145
+ unsubInvalidate();
146
+ };
147
+ }, [queryKey, enabled]);
148
+ useEffect(() => {
149
+ if (!enabled || !lifecycleRef.current.initialized) return;
150
+ const prevContext = controller.getContext();
151
+ controller.update(prevContext);
152
+ }, [pluginOptsKey]);
153
+ const abort = useCallback(() => {
154
+ abortRef.current();
155
+ }, []);
156
+ const refetch = useCallback(() => {
157
+ return executeWithTracking(true);
158
+ }, [executeWithTracking]);
159
+ const entry = stateManager.getCache(queryKey);
160
+ const pluginResultData = entry?.pluginResult ? Object.fromEntries(entry.pluginResult) : {};
161
+ const opts = capturedCall.options;
162
+ const inputInner = {};
163
+ if (opts?.query !== void 0) {
164
+ inputInner.query = opts.query;
165
+ }
166
+ if (opts?.body !== void 0) {
167
+ inputInner.body = opts.body;
168
+ }
169
+ if (opts?.formData !== void 0) {
170
+ inputInner.formData = opts.formData;
171
+ }
172
+ if (opts?.params !== void 0) {
173
+ inputInner.params = opts.params;
174
+ }
175
+ const inputField = Object.keys(inputInner).length > 0 ? { input: inputInner } : {};
176
+ const hasData = state.data !== void 0;
177
+ const loading = requestState.isPending && !hasData;
178
+ const fetching = requestState.isPending;
179
+ const result = {
180
+ ...pluginResultData,
181
+ ...inputField,
182
+ data: state.data,
183
+ error: requestState.error ?? state.error,
184
+ loading,
185
+ fetching,
186
+ abort,
187
+ refetch
188
+ };
189
+ return result;
190
+ };
191
+ }
192
+
193
+ // src/useWrite/index.ts
194
+ import {
195
+ useSyncExternalStore as useSyncExternalStore2,
196
+ useRef as useRef2,
197
+ useCallback as useCallback2,
198
+ useState as useState2,
199
+ useId as useId2
200
+ } from "react";
201
+ import {
202
+ createOperationController as createOperationController2,
203
+ createSelectorProxy as createSelectorProxy2,
204
+ resolvePath as resolvePath2,
205
+ resolveTags as resolveTags2
206
+ } from "@spoosh/core";
207
+ function createUseWrite(options) {
208
+ const { api, stateManager, pluginExecutor, eventEmitter } = options;
209
+ return function useWrite(writeFn) {
210
+ const hookId = useId2();
211
+ const selectorResultRef = useRef2({
212
+ call: null,
213
+ selector: null
214
+ });
215
+ const selectorProxy = createSelectorProxy2((result2) => {
216
+ selectorResultRef.current = result2;
217
+ });
218
+ writeFn(selectorProxy);
219
+ const selectedEndpoint = selectorResultRef.current.selector;
220
+ if (!selectedEndpoint) {
221
+ throw new Error(
222
+ "useWrite requires selecting an HTTP method ($post, $put, $patch, $delete). Example: useWrite((api) => api.posts.$post)"
223
+ );
224
+ }
225
+ const queryKey = stateManager.createQueryKey({
226
+ path: selectedEndpoint.path,
227
+ method: selectedEndpoint.method,
228
+ options: void 0
229
+ });
230
+ const controllerRef = useRef2(null);
231
+ if (!controllerRef.current || controllerRef.current.queryKey !== queryKey) {
232
+ controllerRef.current = {
233
+ controller: createOperationController2({
234
+ operationType: "write",
235
+ path: selectedEndpoint.path,
236
+ method: selectedEndpoint.method,
237
+ tags: [],
238
+ stateManager,
239
+ eventEmitter,
240
+ pluginExecutor,
241
+ hookId,
242
+ fetchFn: async (fetchOpts) => {
243
+ const params = fetchOpts?.params;
244
+ const resolvedPath = resolvePath2(selectedEndpoint.path, params);
245
+ let current = api;
246
+ for (const segment of resolvedPath) {
247
+ current = current[segment];
248
+ }
249
+ const method = current[selectedEndpoint.method];
250
+ return method(fetchOpts);
251
+ }
252
+ }),
253
+ queryKey
254
+ };
255
+ }
256
+ const controller = controllerRef.current.controller;
257
+ const state = useSyncExternalStore2(
258
+ controller.subscribe,
259
+ controller.getState,
260
+ controller.getState
261
+ );
262
+ const [lastTriggerOptions, setLastTriggerOptions] = useState2(void 0);
263
+ const [requestState, setRequestState] = useState2({ isPending: false, error: void 0 });
264
+ const reset = useCallback2(() => {
265
+ stateManager.deleteCache(queryKey);
266
+ setRequestState({ isPending: false, error: void 0 });
267
+ }, [queryKey]);
268
+ const abort = useCallback2(() => {
269
+ controller.abort();
270
+ }, []);
271
+ const trigger = useCallback2(
272
+ async (triggerOptions) => {
273
+ setLastTriggerOptions(triggerOptions);
274
+ setRequestState((prev) => ({ ...prev, isPending: true }));
275
+ const params = triggerOptions?.params;
276
+ const resolvedPath = resolvePath2(selectedEndpoint.path, params);
277
+ const tags = resolveTags2(triggerOptions, resolvedPath);
278
+ controller.setPluginOptions({ ...triggerOptions, tags });
279
+ try {
280
+ const response = await controller.execute(triggerOptions, {
281
+ force: true
282
+ });
283
+ if (response.error) {
284
+ setRequestState({ isPending: false, error: response.error });
285
+ } else {
286
+ setRequestState({ isPending: false, error: void 0 });
287
+ }
288
+ return response;
289
+ } catch (err) {
290
+ setRequestState({ isPending: false, error: err });
291
+ throw err;
292
+ }
293
+ },
294
+ [selectedEndpoint.path]
295
+ );
296
+ const entry = stateManager.getCache(queryKey);
297
+ const pluginResultData = entry?.pluginResult ? Object.fromEntries(entry.pluginResult) : {};
298
+ const opts = lastTriggerOptions;
299
+ const inputInner = {};
300
+ if (opts?.query !== void 0) {
301
+ inputInner.query = opts.query;
302
+ }
303
+ if (opts?.body !== void 0) {
304
+ inputInner.body = opts.body;
305
+ }
306
+ if (opts?.formData !== void 0) {
307
+ inputInner.formData = opts.formData;
308
+ }
309
+ if (opts?.params !== void 0) {
310
+ inputInner.params = opts.params;
311
+ }
312
+ const inputField = Object.keys(inputInner).length > 0 ? { input: inputInner } : {};
313
+ const loading = requestState.isPending;
314
+ const result = {
315
+ trigger,
316
+ ...pluginResultData,
317
+ ...inputField,
318
+ data: state.data,
319
+ error: requestState.error ?? state.error,
320
+ loading,
321
+ reset,
322
+ abort
323
+ };
324
+ return result;
325
+ };
326
+ }
327
+
328
+ // src/useInfiniteRead/index.ts
329
+ import { useRef as useRef3, useEffect as useEffect2, useSyncExternalStore as useSyncExternalStore3, useId as useId3 } from "react";
330
+ import {
331
+ createInfiniteReadController,
332
+ createSelectorProxy as createSelectorProxy3,
333
+ resolvePath as resolvePath3,
334
+ resolveTags as resolveTags3
335
+ } from "@spoosh/core";
336
+ function createUseInfiniteRead(options) {
337
+ const { api, stateManager, eventEmitter, pluginExecutor } = options;
338
+ return function useInfiniteRead(readFn, readOptions) {
339
+ const {
340
+ enabled = true,
341
+ tags,
342
+ additionalTags,
343
+ canFetchNext,
344
+ nextPageRequest,
345
+ merger,
346
+ canFetchPrev,
347
+ prevPageRequest,
348
+ ...pluginOpts
349
+ } = readOptions;
350
+ const hookId = useId3();
351
+ const selectorResultRef = useRef3({
352
+ call: null,
353
+ selector: null
354
+ });
355
+ const selectorProxy = createSelectorProxy3((result2) => {
356
+ selectorResultRef.current = result2;
357
+ });
358
+ readFn(selectorProxy);
359
+ const capturedCall = selectorResultRef.current.call;
360
+ if (!capturedCall) {
361
+ throw new Error(
362
+ "useInfiniteRead requires calling an HTTP method ($get). Example: useInfiniteRead((api) => api.posts.$get())"
363
+ );
364
+ }
365
+ const requestOptions = capturedCall.options;
366
+ const initialRequest = {
367
+ query: requestOptions?.query,
368
+ params: requestOptions?.params,
369
+ body: requestOptions?.body
370
+ };
371
+ const baseOptionsForKey = {
372
+ ...capturedCall.options,
373
+ query: void 0,
374
+ params: void 0,
375
+ body: void 0
376
+ };
377
+ const resolvedPath = resolvePath3(capturedCall.path, requestOptions?.params);
378
+ const resolvedTags = resolveTags3({ tags, additionalTags }, resolvedPath);
379
+ const canFetchNextRef = useRef3(canFetchNext);
380
+ const canFetchPrevRef = useRef3(canFetchPrev);
381
+ const nextPageRequestRef = useRef3(nextPageRequest);
382
+ const prevPageRequestRef = useRef3(prevPageRequest);
383
+ const mergerRef = useRef3(merger);
384
+ canFetchNextRef.current = canFetchNext;
385
+ canFetchPrevRef.current = canFetchPrev;
386
+ nextPageRequestRef.current = nextPageRequest;
387
+ prevPageRequestRef.current = prevPageRequest;
388
+ mergerRef.current = merger;
389
+ const queryKey = stateManager.createQueryKey({
390
+ path: capturedCall.path,
391
+ method: capturedCall.method,
392
+ options: baseOptionsForKey
393
+ });
394
+ const controllerRef = useRef3(null);
395
+ if (!controllerRef.current || controllerRef.current.queryKey !== queryKey) {
396
+ controllerRef.current = {
397
+ controller: createInfiniteReadController({
398
+ path: capturedCall.path,
399
+ method: capturedCall.method,
400
+ tags: resolvedTags,
401
+ initialRequest,
402
+ baseOptionsForKey,
403
+ canFetchNext: (ctx) => canFetchNextRef.current(ctx),
404
+ canFetchPrev: canFetchPrev ? (ctx) => canFetchPrevRef.current?.(ctx) ?? false : void 0,
405
+ nextPageRequest: (ctx) => nextPageRequestRef.current(ctx),
406
+ prevPageRequest: prevPageRequest ? (ctx) => prevPageRequestRef.current?.(ctx) ?? {} : void 0,
407
+ merger: (responses) => mergerRef.current(responses),
408
+ stateManager,
409
+ eventEmitter,
410
+ pluginExecutor,
411
+ hookId,
412
+ fetchFn: async (opts, signal) => {
413
+ const fetchPath = resolvePath3(capturedCall.path, opts.params);
414
+ let current = api;
415
+ for (const segment of fetchPath) {
416
+ current = current[segment];
417
+ }
418
+ const method = current[capturedCall.method];
419
+ const fetchOptions = {
420
+ ...capturedCall.options,
421
+ query: opts.query,
422
+ params: opts.params,
423
+ body: opts.body,
424
+ signal
425
+ };
426
+ return method(fetchOptions);
427
+ }
428
+ }),
429
+ queryKey
430
+ };
431
+ }
432
+ const controller = controllerRef.current.controller;
433
+ controller.setPluginOptions(pluginOpts);
434
+ const state = useSyncExternalStore3(
435
+ controller.subscribe,
436
+ controller.getState,
437
+ controller.getState
438
+ );
439
+ const fetchingDirection = controller.getFetchingDirection();
440
+ const fetching = fetchingDirection !== null;
441
+ const fetchingNext = fetchingDirection === "next";
442
+ const fetchingPrev = fetchingDirection === "prev";
443
+ const loading = fetching && state.data === void 0;
444
+ const lifecycleRef = useRef3({
445
+ initialized: false,
446
+ prevContext: null
447
+ });
448
+ useEffect2(() => {
449
+ return () => {
450
+ controllerRef.current?.controller.unmount();
451
+ lifecycleRef.current.initialized = false;
452
+ };
453
+ }, []);
454
+ useEffect2(() => {
455
+ controller.mount();
456
+ lifecycleRef.current.initialized = true;
457
+ const unsubInvalidate = eventEmitter.on(
458
+ "invalidate",
459
+ (invalidatedTags) => {
460
+ const hasMatch = invalidatedTags.some(
461
+ (tag) => resolvedTags.includes(tag)
462
+ );
463
+ if (hasMatch) {
464
+ controller.refetch();
465
+ }
466
+ }
467
+ );
468
+ return () => {
469
+ unsubInvalidate();
470
+ };
471
+ }, []);
472
+ useEffect2(() => {
473
+ if (!lifecycleRef.current.initialized) return;
474
+ if (enabled) {
475
+ const currentState = controller.getState();
476
+ const isFetching = controller.getFetchingDirection() !== null;
477
+ if (currentState.data === void 0 && !isFetching) {
478
+ controller.fetchNext();
479
+ }
480
+ }
481
+ }, [enabled]);
482
+ useEffect2(() => {
483
+ if (!enabled || !lifecycleRef.current.initialized) return;
484
+ const prevContext = controller.getContext();
485
+ controller.update(prevContext);
486
+ }, [JSON.stringify(pluginOpts)]);
487
+ const result = {
488
+ data: state.data,
489
+ allResponses: state.allResponses,
490
+ loading,
491
+ fetching,
492
+ fetchingNext,
493
+ fetchingPrev,
494
+ canFetchNext: state.canFetchNext,
495
+ canFetchPrev: state.canFetchPrev,
496
+ fetchNext: controller.fetchNext,
497
+ fetchPrev: controller.fetchPrev,
498
+ refetch: controller.refetch,
499
+ abort: controller.abort,
500
+ error: state.error
501
+ };
502
+ return result;
503
+ };
504
+ }
505
+
506
+ // src/createReactSpoosh/index.ts
507
+ function createReactSpoosh(instance) {
508
+ const { api, stateManager, eventEmitter, pluginExecutor } = instance;
509
+ const useRead = createUseRead({
510
+ api,
511
+ stateManager,
512
+ eventEmitter,
513
+ pluginExecutor
514
+ });
515
+ const useWrite = createUseWrite({
516
+ api,
517
+ stateManager,
518
+ eventEmitter,
519
+ pluginExecutor
520
+ });
521
+ const useInfiniteRead = createUseInfiniteRead({
522
+ api,
523
+ stateManager,
524
+ eventEmitter,
525
+ pluginExecutor
526
+ });
527
+ const instanceApiContext = {
528
+ api,
529
+ stateManager,
530
+ eventEmitter,
531
+ pluginExecutor
532
+ };
533
+ const plugins = pluginExecutor.getPlugins();
534
+ const instanceApis = plugins.reduce(
535
+ (acc, plugin) => {
536
+ if (plugin.instanceApi) {
537
+ return { ...acc, ...plugin.instanceApi(instanceApiContext) };
538
+ }
539
+ return acc;
540
+ },
541
+ {}
542
+ );
543
+ return {
544
+ useRead,
545
+ useWrite,
546
+ useInfiniteRead,
547
+ ...instanceApis
548
+ };
549
+ }
550
+ export {
551
+ createReactSpoosh,
552
+ createUseInfiniteRead,
553
+ createUseRead,
554
+ createUseWrite
555
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@spoosh/react",
3
+ "version": "0.1.0-beta.0",
4
+ "license": "MIT",
5
+ "description": "React hooks for Spoosh API client",
6
+ "keywords": [
7
+ "spoosh",
8
+ "react",
9
+ "hooks",
10
+ "api-client",
11
+ "useQuery",
12
+ "useMutation"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/nxnom/spoosh.git",
17
+ "directory": "packages/react"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/nxnom/spoosh/issues"
21
+ },
22
+ "homepage": "https://spoosh.dev/docs/integrations/react",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.mjs",
33
+ "require": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@spoosh/core": "0.1.0-beta.0"
38
+ },
39
+ "peerDependencies": {
40
+ "react": "^18 || ^19"
41
+ },
42
+ "scripts": {
43
+ "dev": "tsup --watch",
44
+ "build": "tsup",
45
+ "typecheck": "tsc --noEmit",
46
+ "lint": "eslint src --max-warnings 0",
47
+ "format": "prettier --write 'src/**/*.ts'"
48
+ }
49
+ }