@spoosh/react 0.11.0 → 0.13.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 CHANGED
@@ -402,13 +402,14 @@ function createUseWrite(options) {
402
402
  return useWrite;
403
403
  }
404
404
 
405
- // src/useInfiniteRead/index.ts
405
+ // src/usePages/index.ts
406
406
  import {
407
407
  useRef as useRef3,
408
408
  useEffect as useEffect2,
409
409
  useSyncExternalStore as useSyncExternalStore3,
410
410
  useId as useId3,
411
- useState as useState3
411
+ useState as useState3,
412
+ useCallback as useCallback3
412
413
  } from "react";
413
414
  import {
414
415
  createInfiniteReadController,
@@ -416,9 +417,9 @@ import {
416
417
  resolvePath as resolvePath3,
417
418
  resolveTags as resolveTags3
418
419
  } from "@spoosh/core";
419
- function createUseInfiniteRead(options) {
420
+ function createUsePages(options) {
420
421
  const { api, stateManager, eventEmitter, pluginExecutor } = options;
421
- return function useInfiniteRead(readFn, readOptions) {
422
+ return function usePages(readFn, readOptions) {
422
423
  const {
423
424
  enabled = true,
424
425
  tags,
@@ -441,7 +442,7 @@ function createUseInfiniteRead(options) {
441
442
  const capturedCall = selectorResultRef.current.call;
442
443
  if (!capturedCall) {
443
444
  throw new Error(
444
- 'useInfiniteRead requires calling an HTTP method (GET). Example: useInfiniteRead((api) => api("posts").GET())'
445
+ 'usePages requires calling an HTTP method (GET). Example: usePages((api) => api("posts").GET())'
445
446
  );
446
447
  }
447
448
  const requestOptions = capturedCall.options;
@@ -451,12 +452,6 @@ function createUseInfiniteRead(options) {
451
452
  params: requestOptions?.params,
452
453
  body: requestOptions?.body
453
454
  };
454
- const baseOptionsForKey = {
455
- ...capturedCall.options,
456
- query: void 0,
457
- params: void 0,
458
- body: void 0
459
- };
460
455
  const resolvedPath = resolvePath3(pathSegments, requestOptions?.params);
461
456
  const resolvedTags = resolveTags3({ tags }, resolvedPath);
462
457
  const canFetchNextRef = useRef3(canFetchNext);
@@ -472,22 +467,31 @@ function createUseInfiniteRead(options) {
472
467
  const queryKey = stateManager.createQueryKey({
473
468
  path: capturedCall.path,
474
469
  method: capturedCall.method,
475
- options: baseOptionsForKey
470
+ options: capturedCall.options
471
+ });
472
+ const lifecycleRef = useRef3({
473
+ initialized: false,
474
+ prevContext: null,
475
+ lastQueryKey: null
476
476
  });
477
477
  const controllerRef = useRef3(null);
478
- if (!controllerRef.current || controllerRef.current.queryKey !== queryKey) {
478
+ const queryKeyChanged = controllerRef.current !== null && controllerRef.current.queryKey !== queryKey;
479
+ if (queryKeyChanged) {
480
+ lifecycleRef.current.prevContext = controllerRef.current.controller.getContext();
481
+ lifecycleRef.current.initialized = false;
482
+ }
483
+ if (!controllerRef.current || queryKeyChanged) {
479
484
  controllerRef.current = {
480
485
  controller: createInfiniteReadController({
481
486
  path: capturedCall.path,
482
487
  method: capturedCall.method,
483
488
  tags: resolvedTags,
484
489
  initialRequest,
485
- baseOptionsForKey,
486
- canFetchNext: (ctx) => canFetchNextRef.current(ctx),
490
+ canFetchNext: canFetchNext ? (ctx) => canFetchNextRef.current?.(ctx) ?? false : void 0,
487
491
  canFetchPrev: canFetchPrev ? (ctx) => canFetchPrevRef.current?.(ctx) ?? false : void 0,
488
- nextPageRequest: (ctx) => nextPageRequestRef.current(ctx),
492
+ nextPageRequest: nextPageRequest ? (ctx) => nextPageRequestRef.current?.(ctx) ?? {} : void 0,
489
493
  prevPageRequest: prevPageRequest ? (ctx) => prevPageRequestRef.current?.(ctx) ?? {} : void 0,
490
- merger: (responses) => mergerRef.current(responses),
494
+ merger: (pages) => mergerRef.current(pages),
491
495
  stateManager,
492
496
  eventEmitter,
493
497
  pluginExecutor,
@@ -497,9 +501,7 @@ function createUseInfiniteRead(options) {
497
501
  const method = pathMethods[capturedCall.method];
498
502
  const fetchOptions = {
499
503
  ...capturedCall.options,
500
- query: opts.query,
501
- params: opts.params,
502
- body: opts.body,
504
+ ...opts,
503
505
  signal
504
506
  };
505
507
  return method(fetchOptions);
@@ -510,11 +512,12 @@ function createUseInfiniteRead(options) {
510
512
  }
511
513
  const controller = controllerRef.current.controller;
512
514
  controller.setPluginOptions(pluginOpts);
513
- const state = useSyncExternalStore3(
514
- controller.subscribe,
515
- controller.getState,
516
- controller.getState
515
+ const subscribe = useCallback3(
516
+ (callback) => controller.subscribe(callback),
517
+ [controller]
517
518
  );
519
+ const getSnapshot = useCallback3(() => controller.getState(), [controller]);
520
+ const state = useSyncExternalStore3(subscribe, getSnapshot, getSnapshot);
518
521
  const [isPending, setIsPending] = useState3(() => {
519
522
  return enabled && state.data === void 0;
520
523
  });
@@ -524,10 +527,6 @@ function createUseInfiniteRead(options) {
524
527
  const fetchingPrev = fetchingDirection === "prev";
525
528
  const hasData = state.data !== void 0;
526
529
  const loading = (isPending || fetching) && !hasData;
527
- const lifecycleRef = useRef3({
528
- initialized: false,
529
- prevContext: null
530
- });
531
530
  const tagsKey = JSON.stringify(tags);
532
531
  useEffect2(() => {
533
532
  return () => {
@@ -536,8 +535,27 @@ function createUseInfiniteRead(options) {
536
535
  };
537
536
  }, []);
538
537
  useEffect2(() => {
539
- controller.mount();
540
- lifecycleRef.current.initialized = true;
538
+ if (!enabled) return;
539
+ const { initialized, prevContext, lastQueryKey } = lifecycleRef.current;
540
+ const isQueryKeyChange = lastQueryKey !== null && lastQueryKey !== queryKey;
541
+ if (!initialized) {
542
+ controller.mount();
543
+ lifecycleRef.current.initialized = true;
544
+ if (prevContext) {
545
+ controller.update(prevContext);
546
+ lifecycleRef.current.prevContext = null;
547
+ }
548
+ }
549
+ lifecycleRef.current.lastQueryKey = queryKey;
550
+ const currentState = controller.getState();
551
+ const isFetching = controller.getFetchingDirection() !== null;
552
+ if (isQueryKeyChange) {
553
+ setIsPending(true);
554
+ controller.trigger({ force: false }).finally(() => setIsPending(false));
555
+ } else if (currentState.data === void 0 && !isFetching) {
556
+ setIsPending(true);
557
+ controller.fetchNext().finally(() => setIsPending(false));
558
+ }
541
559
  const unsubInvalidate = eventEmitter.on(
542
560
  "invalidate",
543
561
  (invalidatedTags) => {
@@ -546,41 +564,32 @@ function createUseInfiniteRead(options) {
546
564
  );
547
565
  if (hasMatch) {
548
566
  setIsPending(true);
549
- controller.refetch().finally(() => setIsPending(false));
567
+ controller.trigger().finally(() => setIsPending(false));
550
568
  }
551
569
  }
552
570
  );
553
571
  const unsubRefetchAll = eventEmitter.on("refetchAll", () => {
554
572
  setIsPending(true);
555
- controller.refetch().finally(() => setIsPending(false));
573
+ controller.trigger().finally(() => setIsPending(false));
556
574
  });
557
575
  return () => {
576
+ controller.unmount();
558
577
  unsubInvalidate();
559
578
  unsubRefetchAll();
560
579
  };
561
- }, [tagsKey]);
562
- useEffect2(() => {
563
- if (!lifecycleRef.current.initialized) return;
564
- if (enabled) {
565
- const currentState = controller.getState();
566
- const isFetching = controller.getFetchingDirection() !== null;
567
- if (currentState.data === void 0 && !isFetching) {
568
- setIsPending(true);
569
- controller.fetchNext().finally(() => setIsPending(false));
570
- }
571
- }
572
- }, [enabled]);
580
+ }, [queryKey, enabled, tagsKey]);
581
+ const pluginOptsKey = JSON.stringify(pluginOpts);
573
582
  useEffect2(() => {
574
583
  if (!enabled || !lifecycleRef.current.initialized) return;
575
584
  const prevContext = controller.getContext();
576
585
  controller.update(prevContext);
577
- }, [JSON.stringify(pluginOpts)]);
578
- const entry = stateManager.getCache(queryKey);
579
- const pluginResultData = entry?.meta ? Object.fromEntries(entry.meta) : {};
586
+ }, [pluginOptsKey]);
587
+ const trigger = async (options2) => {
588
+ await controller.trigger(options2);
589
+ };
580
590
  const result = {
581
- meta: pluginResultData,
582
591
  data: state.data,
583
- allResponses: state.allResponses,
592
+ pages: state.pages,
584
593
  loading,
585
594
  fetching,
586
595
  fetchingNext,
@@ -589,7 +598,7 @@ function createUseInfiniteRead(options) {
589
598
  canFetchPrev: state.canFetchPrev,
590
599
  fetchNext: controller.fetchNext,
591
600
  fetchPrev: controller.fetchPrev,
592
- trigger: controller.refetch,
601
+ trigger,
593
602
  abort: controller.abort,
594
603
  error: state.error
595
604
  };
@@ -666,32 +675,354 @@ function createUseQueue(options) {
666
675
  return useQueue;
667
676
  }
668
677
 
678
+ // src/useSubscription/index.ts
679
+ import {
680
+ useSyncExternalStore as useSyncExternalStore5,
681
+ useRef as useRef5,
682
+ useEffect as useEffect4,
683
+ useCallback as useCallback4,
684
+ useState as useState4
685
+ } from "react";
686
+ import {
687
+ createSelectorProxy as createSelectorProxy5,
688
+ createSubscriptionController
689
+ } from "@spoosh/core";
690
+ function createUseSubscription(options) {
691
+ const { stateManager, eventEmitter, pluginExecutor } = options;
692
+ function useSubscription(subFn, subOptions) {
693
+ const { enabled = true, adapter, operationType } = subOptions;
694
+ const selectorResultRef = useRef5({
695
+ call: null,
696
+ selector: null
697
+ });
698
+ const selectorProxy = createSelectorProxy5((result) => {
699
+ selectorResultRef.current = result;
700
+ });
701
+ subFn(selectorProxy);
702
+ const capturedCall = selectorResultRef.current.call;
703
+ if (!capturedCall) {
704
+ throw new Error("useSubscription requires calling a method");
705
+ }
706
+ const queryKey = stateManager.createQueryKey({
707
+ path: capturedCall.path,
708
+ method: capturedCall.method,
709
+ options: capturedCall.options
710
+ });
711
+ const controllerRef = useRef5(null);
712
+ const subscriptionVersionRef = useRef5(0);
713
+ const getOrCreateController = useCallback4(() => {
714
+ if (controllerRef.current) {
715
+ return controllerRef.current;
716
+ }
717
+ const controller = createSubscriptionController({
718
+ channel: capturedCall.path,
719
+ baseAdapter: adapter,
720
+ stateManager,
721
+ eventEmitter,
722
+ pluginExecutor,
723
+ queryKey,
724
+ operationType,
725
+ path: capturedCall.path,
726
+ method: capturedCall.method
727
+ });
728
+ controllerRef.current = controller;
729
+ return controller;
730
+ }, [
731
+ queryKey,
732
+ adapter,
733
+ operationType,
734
+ capturedCall.path,
735
+ capturedCall.method
736
+ ]);
737
+ const subscribe = useCallback4(
738
+ (callback) => {
739
+ const controller = getOrCreateController();
740
+ return controller.subscribe(callback);
741
+ },
742
+ [getOrCreateController]
743
+ );
744
+ const emptyStateRef = useRef5({
745
+ data: void 0,
746
+ error: void 0,
747
+ isConnected: false
748
+ });
749
+ const getSnapshot = useCallback4(() => {
750
+ if (!controllerRef.current) {
751
+ return emptyStateRef.current;
752
+ }
753
+ return controllerRef.current.getState();
754
+ }, []);
755
+ const state = useSyncExternalStore5(subscribe, getSnapshot, getSnapshot);
756
+ const [isPending, setIsPending] = useState4(enabled);
757
+ useEffect4(() => {
758
+ if (!enabled) {
759
+ return;
760
+ }
761
+ setIsPending(true);
762
+ const controller = getOrCreateController();
763
+ controller.mount();
764
+ controller.subscribe();
765
+ return () => {
766
+ subscriptionVersionRef.current++;
767
+ controller.unsubscribe();
768
+ };
769
+ }, [queryKey, enabled, getOrCreateController]);
770
+ useEffect4(() => {
771
+ if (state.isConnected || state.data !== void 0 || state.error !== void 0) {
772
+ setIsPending(false);
773
+ }
774
+ }, [state.isConnected, state.data, state.error]);
775
+ const disconnect = useCallback4(() => {
776
+ subscriptionVersionRef.current++;
777
+ if (controllerRef.current) {
778
+ controllerRef.current.unsubscribe();
779
+ }
780
+ }, []);
781
+ const trigger = useCallback4(async () => {
782
+ setIsPending(true);
783
+ subscriptionVersionRef.current++;
784
+ const controller = getOrCreateController();
785
+ controller.unsubscribe();
786
+ controller.mount();
787
+ await controller.subscribe();
788
+ }, [getOrCreateController]);
789
+ const loading = isPending;
790
+ return {
791
+ meta: {},
792
+ data: state.data,
793
+ error: state.error,
794
+ loading,
795
+ isConnected: state.isConnected,
796
+ _queryKey: queryKey,
797
+ _subscriptionVersion: subscriptionVersionRef.current,
798
+ trigger,
799
+ disconnect
800
+ };
801
+ }
802
+ return useSubscription;
803
+ }
804
+
805
+ // src/useSSE/index.ts
806
+ import { useState as useState5, useRef as useRef6, useEffect as useEffect5, useCallback as useCallback5, useMemo } from "react";
807
+ import { createSelectorProxy as createSelectorProxy6 } from "@spoosh/core";
808
+ import { resolveParser, resolveAccumulator } from "@spoosh/transport-sse";
809
+ function isSSETransport(transport) {
810
+ return "createSubscriptionAdapter" in transport && typeof transport.createSubscriptionAdapter === "function";
811
+ }
812
+ function createUseSSE(options) {
813
+ const { eventEmitter, transports, config } = options;
814
+ const useSubscription = createUseSubscription(options);
815
+ function useSSE(subFn, sseOptions) {
816
+ const {
817
+ enabled = true,
818
+ events,
819
+ parse = "auto",
820
+ accumulate = "replace",
821
+ maxRetries,
822
+ retryDelay
823
+ } = sseOptions ?? {};
824
+ const transport = transports.get("sse");
825
+ if (!transport) {
826
+ throw new Error(
827
+ "SSE transport not registered. Make sure to register an SSE transport before using useSSE."
828
+ );
829
+ }
830
+ if (!isSSETransport(transport)) {
831
+ throw new Error(
832
+ "SSE transport does not implement createSubscriptionAdapter."
833
+ );
834
+ }
835
+ const selectorResultRef = useRef6({
836
+ call: null,
837
+ selector: null
838
+ });
839
+ const selectorProxy = createSelectorProxy6((result) => {
840
+ selectorResultRef.current = result;
841
+ });
842
+ subFn(selectorProxy);
843
+ const capturedCall = selectorResultRef.current.call;
844
+ if (!capturedCall) {
845
+ throw new Error("useSSE requires calling a method");
846
+ }
847
+ const currentOptionsRef = useRef6(
848
+ capturedCall.options
849
+ );
850
+ const adapter = useMemo(
851
+ () => transport.createSubscriptionAdapter({
852
+ channel: capturedCall.path,
853
+ method: capturedCall.method,
854
+ baseUrl: config.baseUrl,
855
+ globalHeaders: config.defaultOptions.headers,
856
+ getRequestOptions: () => currentOptionsRef.current,
857
+ eventEmitter,
858
+ devtoolMeta: events ? { listenedEvents: events } : void 0
859
+ }),
860
+ [capturedCall.path, capturedCall.method]
861
+ );
862
+ const [accumulatedData, setAccumulatedData] = useState5({});
863
+ const eventSet = useMemo(
864
+ () => events ? new Set(events) : null,
865
+ [events?.join(",")]
866
+ );
867
+ const parseRef = useRef6(parse);
868
+ const accumulateRef = useRef6(accumulate);
869
+ parseRef.current = parse;
870
+ accumulateRef.current = accumulate;
871
+ const optionsRef = useRef6({
872
+ maxRetries,
873
+ retryDelay
874
+ });
875
+ optionsRef.current = { maxRetries, retryDelay };
876
+ const subscription = useSubscription(subFn, {
877
+ enabled,
878
+ adapter,
879
+ operationType: transport.operationType
880
+ });
881
+ const prevVersionRef = useRef6(subscription._subscriptionVersion);
882
+ const lastMessageIndexRef = useRef6({});
883
+ useEffect5(() => {
884
+ if (subscription._subscriptionVersion !== prevVersionRef.current) {
885
+ setAccumulatedData({});
886
+ lastMessageIndexRef.current = {};
887
+ }
888
+ prevVersionRef.current = subscription._subscriptionVersion;
889
+ }, [subscription._subscriptionVersion]);
890
+ useEffect5(() => {
891
+ const data = subscription.data;
892
+ if (!data) {
893
+ return;
894
+ }
895
+ if (eventSet && !eventSet.has(data.event)) {
896
+ return;
897
+ }
898
+ const parser = resolveParser(parseRef.current, data.event);
899
+ let parsed;
900
+ try {
901
+ parsed = parser(data.data);
902
+ } catch {
903
+ parsed = data.data;
904
+ }
905
+ if (parsed === void 0) {
906
+ return;
907
+ }
908
+ const accumulator = resolveAccumulator(accumulateRef.current, data.event);
909
+ const parsedObj = parsed;
910
+ const messageIndex = typeof parsedObj?.index === "number" ? parsedObj.index : void 0;
911
+ if (messageIndex !== void 0) {
912
+ const lastIndex = lastMessageIndexRef.current[data.event];
913
+ if (lastIndex !== void 0 && messageIndex < lastIndex) {
914
+ setAccumulatedData({});
915
+ lastMessageIndexRef.current = {};
916
+ }
917
+ lastMessageIndexRef.current[data.event] = messageIndex;
918
+ }
919
+ setAccumulatedData((prev) => {
920
+ const previousEventData = prev[data.event];
921
+ let newEventData;
922
+ try {
923
+ newEventData = accumulator(previousEventData, parsed);
924
+ } catch {
925
+ newEventData = parsed;
926
+ }
927
+ const newAccumulated = {
928
+ ...prev,
929
+ [data.event]: newEventData
930
+ };
931
+ eventEmitter?.emit(
932
+ "spoosh:subscription:accumulate",
933
+ {
934
+ queryKey: subscription._queryKey,
935
+ eventType: data.event,
936
+ accumulatedData: newAccumulated,
937
+ timestamp: Date.now()
938
+ }
939
+ );
940
+ return newAccumulated;
941
+ });
942
+ }, [subscription.data, subscription._queryKey, eventSet]);
943
+ const reset = useCallback5(() => {
944
+ setAccumulatedData({});
945
+ }, []);
946
+ const trigger = useCallback5(
947
+ async (opts) => {
948
+ reset();
949
+ const triggerOpts = {
950
+ ...opts ?? {},
951
+ maxRetries: optionsRef.current.maxRetries,
952
+ retryDelay: optionsRef.current.retryDelay
953
+ };
954
+ currentOptionsRef.current = {
955
+ ...currentOptionsRef.current,
956
+ ...triggerOpts
957
+ };
958
+ await subscription.trigger(triggerOpts);
959
+ },
960
+ [subscription.trigger, reset]
961
+ );
962
+ return {
963
+ data: Object.keys(accumulatedData).length ? accumulatedData : void 0,
964
+ error: subscription.error,
965
+ isConnected: subscription.isConnected,
966
+ loading: subscription.loading,
967
+ meta: {},
968
+ trigger,
969
+ disconnect: subscription.disconnect,
970
+ reset
971
+ };
972
+ }
973
+ return useSSE;
974
+ }
975
+
669
976
  // src/create/index.ts
670
977
  function create(instance) {
671
- const { api, stateManager, eventEmitter, pluginExecutor } = instance;
978
+ const { api, stateManager, eventEmitter, pluginExecutor, transports } = instance;
672
979
  const useRead = createUseRead({
673
980
  api,
674
981
  stateManager,
675
982
  eventEmitter,
676
- pluginExecutor
983
+ pluginExecutor,
984
+ transports,
985
+ config: instance.config
677
986
  });
678
987
  const useWrite = createUseWrite({
679
988
  api,
680
989
  stateManager,
681
990
  eventEmitter,
682
- pluginExecutor
991
+ pluginExecutor,
992
+ transports,
993
+ config: instance.config
683
994
  });
684
- const useInfiniteRead = createUseInfiniteRead({
995
+ const usePages = createUsePages({
685
996
  api,
686
997
  stateManager,
687
998
  eventEmitter,
688
- pluginExecutor
999
+ pluginExecutor,
1000
+ transports,
1001
+ config: instance.config
689
1002
  });
690
1003
  const useQueue = createUseQueue({
691
1004
  api,
692
1005
  stateManager,
693
1006
  eventEmitter,
694
- pluginExecutor
1007
+ pluginExecutor,
1008
+ transports,
1009
+ config: instance.config
1010
+ });
1011
+ const useSubscription = createUseSubscription({
1012
+ api,
1013
+ stateManager,
1014
+ eventEmitter,
1015
+ pluginExecutor,
1016
+ transports,
1017
+ config: instance.config
1018
+ });
1019
+ const useSSE = createUseSSE({
1020
+ api,
1021
+ stateManager,
1022
+ eventEmitter,
1023
+ pluginExecutor,
1024
+ transports,
1025
+ config: instance.config
695
1026
  });
696
1027
  const plugins = pluginExecutor.getPlugins();
697
1028
  const setupContext = {
@@ -720,8 +1051,10 @@ function create(instance) {
720
1051
  return {
721
1052
  useRead,
722
1053
  useWrite,
723
- useInfiniteRead,
1054
+ usePages,
724
1055
  useQueue,
1056
+ useSubscription,
1057
+ useSSE,
725
1058
  ...instanceApis
726
1059
  };
727
1060
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spoosh/react",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "description": "React hooks for Spoosh API toolkit",
6
6
  "keywords": [
@@ -34,14 +34,21 @@
34
34
  }
35
35
  },
36
36
  "peerDependencies": {
37
- "@spoosh/core": ">=0.14.0",
37
+ "@spoosh/core": ">=0.15.0",
38
+ "@spoosh/transport-sse": ">=0.1.0",
38
39
  "react": "^18 || ^19"
39
40
  },
41
+ "peerDependenciesMeta": {
42
+ "@spoosh/transport-sse": {
43
+ "optional": true
44
+ }
45
+ },
40
46
  "devDependencies": {
41
47
  "@testing-library/react": "^16.0.0",
42
48
  "jsdom": "^26.0.0",
43
- "@spoosh/core": "0.14.0",
44
- "@spoosh/test-utils": "0.2.0"
49
+ "@spoosh/core": "0.16.0",
50
+ "@spoosh/transport-sse": "0.1.0",
51
+ "@spoosh/test-utils": "0.3.0"
45
52
  },
46
53
  "scripts": {
47
54
  "dev": "tsup --watch",