@knocklabs/client 0.21.1 → 0.21.2

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cjs/api.js +1 -1
  3. package/dist/cjs/api.js.map +1 -1
  4. package/dist/cjs/clients/feed/feed.js +1 -1
  5. package/dist/cjs/clients/feed/feed.js.map +1 -1
  6. package/dist/cjs/clients/guide/client.js +1 -1
  7. package/dist/cjs/clients/guide/client.js.map +1 -1
  8. package/dist/cjs/clients/guide/helpers.js +1 -1
  9. package/dist/cjs/clients/guide/helpers.js.map +1 -1
  10. package/dist/cjs/clients/guide/types.js +2 -0
  11. package/dist/cjs/clients/guide/types.js.map +1 -0
  12. package/dist/cjs/helpers.js +1 -1
  13. package/dist/cjs/helpers.js.map +1 -1
  14. package/dist/cjs/index.js +1 -1
  15. package/dist/cjs/knock.js +2 -2
  16. package/dist/cjs/knock.js.map +1 -1
  17. package/dist/cjs/pageVisibility.js +2 -0
  18. package/dist/cjs/pageVisibility.js.map +1 -0
  19. package/dist/esm/api.mjs +27 -12
  20. package/dist/esm/api.mjs.map +1 -1
  21. package/dist/esm/clients/feed/feed.mjs +60 -87
  22. package/dist/esm/clients/feed/feed.mjs.map +1 -1
  23. package/dist/esm/clients/guide/client.mjs +191 -147
  24. package/dist/esm/clients/guide/client.mjs.map +1 -1
  25. package/dist/esm/clients/guide/helpers.mjs +34 -44
  26. package/dist/esm/clients/guide/helpers.mjs.map +1 -1
  27. package/dist/esm/clients/guide/types.mjs +13 -0
  28. package/dist/esm/clients/guide/types.mjs.map +1 -0
  29. package/dist/esm/helpers.mjs +19 -4
  30. package/dist/esm/helpers.mjs.map +1 -1
  31. package/dist/esm/index.mjs +14 -12
  32. package/dist/esm/index.mjs.map +1 -1
  33. package/dist/esm/knock.mjs +31 -29
  34. package/dist/esm/knock.mjs.map +1 -1
  35. package/dist/esm/pageVisibility.mjs +31 -0
  36. package/dist/esm/pageVisibility.mjs.map +1 -0
  37. package/dist/types/api.d.ts +4 -0
  38. package/dist/types/api.d.ts.map +1 -1
  39. package/dist/types/clients/feed/feed.d.ts +1 -11
  40. package/dist/types/clients/feed/feed.d.ts.map +1 -1
  41. package/dist/types/clients/feed/interfaces.d.ts +0 -4
  42. package/dist/types/clients/feed/interfaces.d.ts.map +1 -1
  43. package/dist/types/clients/feed/utils.d.ts +0 -2
  44. package/dist/types/clients/feed/utils.d.ts.map +1 -1
  45. package/dist/types/clients/guide/client.d.ts +3 -1
  46. package/dist/types/clients/guide/client.d.ts.map +1 -1
  47. package/dist/types/clients/guide/helpers.d.ts +1 -7
  48. package/dist/types/clients/guide/helpers.d.ts.map +1 -1
  49. package/dist/types/clients/guide/index.d.ts +2 -1
  50. package/dist/types/clients/guide/index.d.ts.map +1 -1
  51. package/dist/types/clients/guide/types.d.ts +21 -0
  52. package/dist/types/clients/guide/types.d.ts.map +1 -1
  53. package/dist/types/helpers.d.ts +19 -0
  54. package/dist/types/helpers.d.ts.map +1 -1
  55. package/dist/types/interfaces.d.ts +2 -0
  56. package/dist/types/interfaces.d.ts.map +1 -1
  57. package/dist/types/knock.d.ts +1 -0
  58. package/dist/types/knock.d.ts.map +1 -1
  59. package/dist/types/pageVisibility.d.ts +22 -0
  60. package/dist/types/pageVisibility.d.ts.map +1 -0
  61. package/package.json +1 -1
  62. package/src/api.ts +30 -0
  63. package/src/clients/feed/feed.ts +0 -73
  64. package/src/clients/feed/interfaces.ts +0 -7
  65. package/src/clients/guide/client.ts +117 -32
  66. package/src/clients/guide/helpers.ts +0 -12
  67. package/src/clients/guide/index.ts +3 -0
  68. package/src/clients/guide/types.ts +34 -0
  69. package/src/helpers.ts +39 -0
  70. package/src/interfaces.ts +2 -0
  71. package/src/knock.ts +4 -3
  72. package/src/pageVisibility.ts +70 -0
package/src/api.ts CHANGED
@@ -2,11 +2,16 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
2
2
  import axiosRetry from "axios-retry";
3
3
  import { Socket } from "phoenix";
4
4
 
5
+ import { exponentialBackoffFullJitter } from "./helpers";
6
+ import { PageVisibilityManager } from "./pageVisibility";
7
+
5
8
  type ApiClientOptions = {
6
9
  host: string;
7
10
  apiKey: string;
8
11
  userToken: string | undefined;
9
12
  branch?: string;
13
+ /** Automatically disconnect the socket when the page is hidden and reconnect when visible. Defaults to `true`. */
14
+ disconnectOnPageHidden?: boolean;
10
15
  };
11
16
 
12
17
  export interface ApiResponse {
@@ -26,6 +31,7 @@ class ApiClient {
26
31
  private axiosClient: AxiosInstance;
27
32
 
28
33
  public socket: Socket | undefined;
34
+ private pageVisibility: PageVisibilityManager | undefined;
29
35
 
30
36
  constructor(options: ApiClientOptions) {
31
37
  this.host = options.host;
@@ -53,7 +59,23 @@ class ApiClient {
53
59
  api_key: this.apiKey,
54
60
  branch_slug: this.branch,
55
61
  },
62
+ reconnectAfterMs: (tries: number) => {
63
+ return exponentialBackoffFullJitter(tries, {
64
+ baseDelayMs: 1000,
65
+ maxDelayMs: 30_000,
66
+ });
67
+ },
68
+ rejoinAfterMs: (tries: number) => {
69
+ return exponentialBackoffFullJitter(tries, {
70
+ baseDelayMs: 1000,
71
+ maxDelayMs: 60_000,
72
+ });
73
+ },
56
74
  });
75
+
76
+ if (options.disconnectOnPageHidden !== false) {
77
+ this.pageVisibility = new PageVisibilityManager(this.socket);
78
+ }
57
79
  }
58
80
 
59
81
  axiosRetry(this.axiosClient, {
@@ -87,6 +109,14 @@ class ApiClient {
87
109
  }
88
110
  }
89
111
 
112
+ teardown() {
113
+ this.pageVisibility?.teardown();
114
+
115
+ if (this.socket?.isConnected()) {
116
+ this.socket.disconnect();
117
+ }
118
+ }
119
+
90
120
  private canRetryRequest(error: AxiosError) {
91
121
  // Retry Network Errors.
92
122
  if (axiosRetry.isNetworkError(error)) {
@@ -41,8 +41,6 @@ const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
41
41
  mode: "compact",
42
42
  };
43
43
 
44
- const DEFAULT_DISCONNECT_DELAY = 2000;
45
-
46
44
  const CLIENT_REF_ID_PREFIX = "client_";
47
45
 
48
46
  class Feed {
@@ -53,10 +51,7 @@ class Feed {
53
51
  private userFeedId: string;
54
52
  private broadcaster: EventEmitter;
55
53
  private broadcastChannel!: BroadcastChannel | null;
56
- private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
57
54
  private hasSubscribedToRealTimeUpdates: boolean = false;
58
- private visibilityChangeHandler: () => void = () => {};
59
- private visibilityChangeListenerConnected: boolean = false;
60
55
 
61
56
  // The raw store instance, used for binding in React and other environments
62
57
  public store: FeedStore;
@@ -117,13 +112,6 @@ class Feed {
117
112
 
118
113
  this.socketManager?.leave(this);
119
114
 
120
- this.tearDownVisibilityListeners();
121
-
122
- if (this.disconnectTimer) {
123
- clearTimeout(this.disconnectTimer);
124
- this.disconnectTimer = null;
125
- }
126
-
127
115
  if (this.broadcastChannel) {
128
116
  this.broadcastChannel.close();
129
117
  }
@@ -553,8 +541,6 @@ class Feed {
553
541
  __loadingType: undefined,
554
542
  __fetchSource: undefined,
555
543
  __experimentalCrossBrowserUpdates: undefined,
556
- auto_manage_socket_connection: undefined,
557
- auto_manage_socket_connection_delay: undefined,
558
544
  };
559
545
 
560
546
  const result = await this.knock.client().makeRequest({
@@ -815,10 +801,6 @@ class Feed {
815
801
  // In server environments we might not have a socket connection
816
802
  if (!this.socketManager) return;
817
803
 
818
- if (this.defaultOptions.auto_manage_socket_connection) {
819
- this.setUpVisibilityListeners();
820
- }
821
-
822
804
  // If we're initializing but they have previously opted to listen to real-time updates
823
805
  // then we will automatically reconnect on their behalf
824
806
  if (this.hasSubscribedToRealTimeUpdates && this.knock.isAuthenticated()) {
@@ -838,33 +820,6 @@ class Feed {
838
820
  }
839
821
  }
840
822
 
841
- /**
842
- * Listen for changes to document visibility and automatically disconnect
843
- * or reconnect the socket after a delay
844
- */
845
- private setUpVisibilityListeners() {
846
- if (
847
- typeof document === "undefined" ||
848
- this.visibilityChangeListenerConnected
849
- ) {
850
- return;
851
- }
852
-
853
- this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
854
- this.visibilityChangeListenerConnected = true;
855
- document.addEventListener("visibilitychange", this.visibilityChangeHandler);
856
- }
857
-
858
- private tearDownVisibilityListeners() {
859
- if (typeof document === "undefined") return;
860
-
861
- document.removeEventListener(
862
- "visibilitychange",
863
- this.visibilityChangeHandler,
864
- );
865
- this.visibilityChangeListenerConnected = false;
866
- }
867
-
868
823
  private emitEvent(
869
824
  type:
870
825
  | MessageEngagementStatus
@@ -882,34 +837,6 @@ class Feed {
882
837
  // Internal events only need `items:`
883
838
  this.broadcastOverChannel(`items:${type}`, { items });
884
839
  }
885
-
886
- private handleVisibilityChange() {
887
- const disconnectDelay =
888
- this.defaultOptions.auto_manage_socket_connection_delay ??
889
- DEFAULT_DISCONNECT_DELAY;
890
-
891
- const client = this.knock.client();
892
-
893
- if (document.visibilityState === "hidden") {
894
- // When the tab is hidden, clean up the socket connection after a delay
895
- this.disconnectTimer = setTimeout(() => {
896
- client.socket?.disconnect();
897
- this.disconnectTimer = null;
898
- }, disconnectDelay);
899
- } else if (document.visibilityState === "visible") {
900
- // When the tab is visible, clear the disconnect timer if active to cancel disconnecting
901
- // This handles cases where the tab is only briefly hidden to avoid unnecessary disconnects
902
- if (this.disconnectTimer) {
903
- clearTimeout(this.disconnectTimer);
904
- this.disconnectTimer = null;
905
- }
906
-
907
- // If the socket is not connected, try to reconnect
908
- if (!client.socket?.isConnected()) {
909
- client.socket?.connect();
910
- }
911
- }
912
- }
913
840
  }
914
841
 
915
842
  export default Feed;
@@ -34,11 +34,6 @@ export interface FeedClientOptions {
34
34
  trigger_data?: TriggerData;
35
35
  // Optionally enable cross browser feed updates for this feed
36
36
  __experimentalCrossBrowserUpdates?: boolean;
37
- // Optionally automatically manage socket connections on changes to tab visibility (defaults to `false`)
38
- auto_manage_socket_connection?: boolean;
39
- // Optionally set the delay amount in milliseconds when automatically disconnecting sockets from inactive tabs (defaults to `2000`)
40
- // Requires `auto_manage_socket_connection` to be `true`
41
- auto_manage_socket_connection_delay?: number;
42
37
  // Optionally scope notifications to a given date range
43
38
  inserted_at_date_range?: {
44
39
  // Optionally set the start date with a string in ISO 8601 format
@@ -88,8 +83,6 @@ export type FetchFeedOptionsForRequest = Omit<
88
83
  __loadingType: undefined;
89
84
  __fetchSource: undefined;
90
85
  __experimentalCrossBrowserUpdates: undefined;
91
- auto_manage_socket_connection: undefined;
92
- auto_manage_socket_connection_delay: undefined;
93
86
  };
94
87
 
95
88
  export interface ContentBlockBase {
@@ -7,7 +7,6 @@ import Knock from "../../knock";
7
7
 
8
8
  import {
9
9
  DEFAULT_GROUP_KEY,
10
- SelectionResult,
11
10
  byKey,
12
11
  checkStateIfThrottled,
13
12
  findDefaultGroup,
@@ -46,6 +45,8 @@ import {
46
45
  SelectFilterParams,
47
46
  SelectGuideOpts,
48
47
  SelectGuidesOpts,
48
+ SelectQueryLimit,
49
+ SelectionResult,
49
50
  StepMessageState,
50
51
  StoreState,
51
52
  TargetParams,
@@ -150,7 +151,16 @@ const safeJsonParseDebugParams = (value: string): DebugState => {
150
151
  }
151
152
  };
152
153
 
153
- const select = (state: StoreState, filters: SelectFilterParams = {}) => {
154
+ type SelectQueryMetadata = {
155
+ limit: SelectQueryLimit;
156
+ opts: SelectGuideOpts;
157
+ };
158
+
159
+ const select = (
160
+ state: StoreState,
161
+ filters: SelectFilterParams,
162
+ metadata: SelectQueryMetadata,
163
+ ) => {
154
164
  // A map of selected guides as values, with its order index as keys.
155
165
  const result = new SelectionResult();
156
166
 
@@ -175,7 +185,8 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => {
175
185
  result.set(index, guide);
176
186
  }
177
187
 
178
- result.metadata = { guideGroup: defaultGroup };
188
+ result.metadata = { guideGroup: defaultGroup, filters, ...metadata };
189
+
179
190
  return result;
180
191
  };
181
192
 
@@ -617,14 +628,35 @@ export class KnockGuideClient {
617
628
  `[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`,
618
629
  );
619
630
 
620
- const selectedGuide = this.selectGuide(state, filters, opts);
621
- if (!selectedGuide) {
631
+ // 1. First, call selectGuide() using the same filters to ensure we have a
632
+ // group stage open and respect throttling. This isn't the real query, but
633
+ // rather it's a shortcut ahead of handling the actual query result below.
634
+ const selectedGuide = this.selectGuide(state, filters, {
635
+ ...opts,
636
+ // Don't record this result, not the actual query result we need.
637
+ recordSelectQuery: false,
638
+ });
639
+
640
+ // 2. Now make the actual select query with the provided filters and opts,
641
+ // and record the result (as needed). By default, we only record the result
642
+ // while in debugging.
643
+ const { recordSelectQuery = !!state.debug?.debugging } = opts;
644
+ const metadata: SelectQueryMetadata = {
645
+ limit: "all",
646
+ opts: { ...opts, recordSelectQuery },
647
+ };
648
+ const result = select(state, filters, metadata);
649
+ this.maybeRecordSelectResult(result);
650
+
651
+ // 3. Stop if there is not at least one guide to return.
652
+ if (!selectedGuide && !opts.includeThrottled) {
622
653
  return [];
623
654
  }
624
655
 
625
656
  // There should be at least one guide to return here now.
626
- const guides = [...select(state, filters).values()];
657
+ const guides = [...result.values()];
627
658
 
659
+ // 4. If throttled, filter out any throttled guides.
628
660
  if (!opts.includeThrottled && checkStateIfThrottled(state)) {
629
661
  const unthrottledGuides = guides.filter(
630
662
  (g) => g.bypass_global_group_limit,
@@ -657,32 +689,6 @@ export class KnockGuideClient {
657
689
  return undefined;
658
690
  }
659
691
 
660
- const result = select(state, filters);
661
-
662
- if (result.size === 0) {
663
- this.knock.log("[Guide] Selection found zero result");
664
- return undefined;
665
- }
666
-
667
- const [index, guide] = [...result][0]!;
668
- this.knock.log(
669
- `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
670
- );
671
-
672
- // If a guide ignores the group limit, then return immediately to render
673
- // always.
674
- if (guide.bypass_global_group_limit) {
675
- this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
676
- return guide;
677
- }
678
-
679
- // Check if inside the throttle window (i.e. throttled) and if so stop and
680
- // return undefined unless explicitly given the option to include throttled.
681
- if (!opts.includeThrottled && checkStateIfThrottled(state)) {
682
- this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
683
- return undefined;
684
- }
685
-
686
692
  // Starting here to the end of this method represents the core logic of how
687
693
  // "group stage" works. It provides a mechanism for 1) figuring out which
688
694
  // guide components are about to render on a page, 2) determining which
@@ -716,6 +722,35 @@ export class KnockGuideClient {
716
722
  this.stage = this.openGroupStage(); // Assign here to make tsc happy
717
723
  }
718
724
 
725
+ // Must come AFTER we ensure a group stage exists above, so we can record
726
+ // select queries. By default, we only record the result while in debugging.
727
+ const { recordSelectQuery = !!state.debug?.debugging } = opts;
728
+ const metadata: SelectQueryMetadata = {
729
+ limit: "one",
730
+ opts: { ...opts, recordSelectQuery },
731
+ };
732
+ const result = select(state, filters, metadata);
733
+ this.maybeRecordSelectResult(result);
734
+
735
+ if (result.size === 0) {
736
+ this.knock.log("[Guide] Selection found zero result");
737
+ return undefined;
738
+ }
739
+
740
+ const [index, guide] = [...result][0]!;
741
+ this.knock.log(
742
+ `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
743
+ );
744
+
745
+ // If a guide ignores the group limit, then return immediately to render
746
+ // always.
747
+ if (guide.bypass_global_group_limit) {
748
+ this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
749
+ return guide;
750
+ }
751
+
752
+ const throttled = !opts.includeThrottled && checkStateIfThrottled(state);
753
+
719
754
  switch (this.stage.status) {
720
755
  case "open": {
721
756
  this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`);
@@ -725,8 +760,16 @@ export class KnockGuideClient {
725
760
 
726
761
  case "patch": {
727
762
  this.knock.log(`[Guide] Patching the group stage: ${guide.key}`);
763
+ // Refresh the ordered queue in the group stage while continuing to
764
+ // render the currently resolved guide while in patch window, so that
765
+ // we can re-resolve when the group stage closes.
728
766
  this.stage.ordered[index] = guide.key;
729
767
 
768
+ if (throttled) {
769
+ this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
770
+ return undefined;
771
+ }
772
+
730
773
  const ret = this.stage.resolved === guide.key ? guide : undefined;
731
774
  this.knock.log(
732
775
  `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -735,6 +778,11 @@ export class KnockGuideClient {
735
778
  }
736
779
 
737
780
  case "closed": {
781
+ if (throttled) {
782
+ this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
783
+ return undefined;
784
+ }
785
+
738
786
  const ret = this.stage.resolved === guide.key ? guide : undefined;
739
787
  this.knock.log(
740
788
  `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -744,6 +792,42 @@ export class KnockGuideClient {
744
792
  }
745
793
  }
746
794
 
795
+ // Record select query results by accumulating them by 1) key or type first,
796
+ // and then 2) "one" or "all".
797
+ private maybeRecordSelectResult(result: SelectionResult) {
798
+ if (!result.metadata) return;
799
+
800
+ const { opts, filters, limit } = result.metadata;
801
+ if (!opts.recordSelectQuery) return;
802
+ if (!filters.key && !filters.type) return;
803
+ if (!this.stage || this.stage.status === "closed") return;
804
+
805
+ // Deep merge to accumulate the results.
806
+ const queriedByKey = this.stage.results.key || {};
807
+ if (filters.key) {
808
+ queriedByKey[filters.key] = {
809
+ ...(queriedByKey[filters.key] || {}),
810
+ ...{ [limit]: result },
811
+ };
812
+ }
813
+ const queriedByType = this.stage.results.type || {};
814
+ if (filters.type) {
815
+ queriedByType[filters.type] = {
816
+ ...(queriedByType[filters.type] || {}),
817
+ ...{ [limit]: result },
818
+ };
819
+ }
820
+
821
+ this.stage = {
822
+ ...this.stage,
823
+ results: { key: queriedByKey, type: queriedByType },
824
+ };
825
+ }
826
+
827
+ getStage() {
828
+ return this.stage;
829
+ }
830
+
747
831
  private openGroupStage() {
748
832
  this.knock.log("[Guide] Opening a new group stage");
749
833
 
@@ -759,6 +843,7 @@ export class KnockGuideClient {
759
843
  this.stage = {
760
844
  status: "open",
761
845
  ordered: [],
846
+ results: {},
762
847
  timeoutId,
763
848
  };
764
849
 
@@ -3,23 +3,11 @@ import {
3
3
  GuideActivationUrlRuleData,
4
4
  GuideData,
5
5
  GuideGroupData,
6
- KnockGuide,
7
6
  KnockGuideActivationUrlPattern,
8
7
  SelectFilterParams,
9
8
  StoreState,
10
9
  } from "./types";
11
10
 
12
- // Extends the map class to allow having metadata on it, which is used to record
13
- // the guide group context for the selection result (though currently only a
14
- // default global group is supported).
15
- export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
16
- metadata: { guideGroup: GuideGroupData } | undefined;
17
-
18
- constructor() {
19
- super();
20
- }
21
- }
22
-
23
11
  export const formatGroupStage = (stage: GroupStage) => {
24
12
  return `status=${stage.status}, resolved=${stage.resolved}`;
25
13
  };
@@ -3,6 +3,7 @@ export {
3
3
  DEBUG_QUERY_PARAMS,
4
4
  checkActivatable,
5
5
  } from "./client";
6
+ export { checkStateIfThrottled } from "./helpers";
6
7
  export type {
7
8
  KnockGuide,
8
9
  KnockGuideStep,
@@ -12,4 +13,6 @@ export type {
12
13
  SelectGuideOpts as KnockSelectGuideOpts,
13
14
  SelectGuidesOpts as KnockSelectGuidesOpts,
14
15
  StoreState as KnockGuideClientStoreState,
16
+ GroupStage as KnockGuideClientGroupStage,
17
+ SelectionResult as KnockGuideSelectionResult,
15
18
  } from "./types";
@@ -1,5 +1,27 @@
1
1
  import { GenericData } from "@knocklabs/types";
2
2
 
3
+ // i.e. useGuide vs useGuides
4
+ export type SelectQueryLimit = "one" | "all";
5
+
6
+ type SelectionResultMetadata = {
7
+ guideGroup: GuideGroupData;
8
+ // Additional info about the underlying select query behind the result.
9
+ filters: SelectFilterParams;
10
+ limit: SelectQueryLimit;
11
+ opts: SelectGuideOpts;
12
+ };
13
+
14
+ // Extends the map class to allow having metadata on it, which is used to record
15
+ // the guide group context for the selection result (though currently only a
16
+ // default global group is supported).
17
+ export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
18
+ metadata: SelectionResultMetadata | undefined;
19
+
20
+ constructor() {
21
+ super();
22
+ }
23
+ }
24
+
3
25
  //
4
26
  // Fetch guides API
5
27
  //
@@ -237,6 +259,7 @@ export type SelectFilterParams = {
237
259
 
238
260
  export type SelectGuideOpts = {
239
261
  includeThrottled?: boolean;
262
+ recordSelectQuery?: boolean;
240
263
  };
241
264
 
242
265
  export type SelectGuidesOpts = SelectGuideOpts;
@@ -253,9 +276,20 @@ export type ConstructorOpts = {
253
276
  throttleCheckInterval?: number;
254
277
  };
255
278
 
279
+ type SelectionResultByLimit = {
280
+ one?: SelectionResult;
281
+ all?: SelectionResult;
282
+ };
283
+
284
+ type RecordedSelectionResults = {
285
+ key?: Record<KnockGuide["key"], SelectionResultByLimit>;
286
+ type?: Record<KnockGuide["type"], SelectionResultByLimit>;
287
+ };
288
+
256
289
  export type GroupStage = {
257
290
  status: "open" | "closed" | "patch";
258
291
  ordered: Array<KnockGuide["key"]>;
259
292
  resolved?: KnockGuide["key"];
260
293
  timeoutId: ReturnType<typeof setTimeout> | null;
294
+ results: RecordedSelectionResults;
261
295
  };
package/src/helpers.ts CHANGED
@@ -4,3 +4,42 @@ const uuidRegex =
4
4
  export function isValidUuid(uuid: string) {
5
5
  return uuidRegex.test(uuid);
6
6
  }
7
+
8
+ /**
9
+ * Exponential backoff with full jitter and a minimum delay floor.
10
+ *
11
+ * - Uses exponential growth capped at maxDelayMs
12
+ * - Applies full jitter to spread retries uniformly across the window
13
+ * - Enforces a minimum delay to avoid tight retry loops
14
+ *
15
+ * Example (baseDelayMs = 1000):
16
+ * Try 1: 250ms – 1,000ms
17
+ * Try 2: 250ms – 2,000ms
18
+ * Try 3: 250ms – 4,000ms
19
+ * Try 4: 250ms – 8,000ms
20
+ * Try 5+: 250ms – maxDelayMs
21
+ */
22
+ export function exponentialBackoffFullJitter(
23
+ tries: number,
24
+ {
25
+ baseDelayMs,
26
+ maxDelayMs,
27
+ minDelayMs = 250,
28
+ }: {
29
+ baseDelayMs: number;
30
+ maxDelayMs: number;
31
+ minDelayMs?: number;
32
+ },
33
+ ): number {
34
+ const exponentialDelay = Math.min(
35
+ maxDelayMs,
36
+ baseDelayMs * Math.pow(2, Math.max(0, tries - 1)),
37
+ );
38
+
39
+ if (exponentialDelay <= minDelayMs) {
40
+ return minDelayMs;
41
+ }
42
+
43
+ const jitterRange = exponentialDelay - minDelayMs;
44
+ return minDelayMs + Math.floor(Math.random() * jitterRange);
45
+ }
package/src/interfaces.ts CHANGED
@@ -9,6 +9,8 @@ export interface KnockOptions {
9
9
  host?: string;
10
10
  logLevel?: LogLevel;
11
11
  branch?: string;
12
+ /** Automatically disconnect the socket when the page is hidden and reconnect when visible. Defaults to `true`. */
13
+ disconnectOnPageHidden?: boolean;
12
14
  }
13
15
 
14
16
  export interface KnockObject<T = GenericData> {
package/src/knock.ts CHANGED
@@ -26,6 +26,7 @@ class Knock {
26
26
  public userToken?: string;
27
27
  public logLevel?: LogLevel;
28
28
  public readonly branch?: string;
29
+ private readonly disconnectOnPageHidden?: boolean;
29
30
  private tokenExpirationTimer: ReturnType<typeof setTimeout> | null = null;
30
31
  readonly feeds = new FeedClient(this);
31
32
  readonly objects = new ObjectClient(this);
@@ -42,6 +43,7 @@ class Knock {
42
43
  this.host = options.host || DEFAULT_HOST;
43
44
  this.logLevel = options.logLevel;
44
45
  this.branch = options.branch || undefined;
46
+ this.disconnectOnPageHidden = options.disconnectOnPageHidden;
45
47
 
46
48
  this.log("Initialized Knock instance");
47
49
 
@@ -170,9 +172,7 @@ class Knock {
170
172
  if (this.tokenExpirationTimer) {
171
173
  clearTimeout(this.tokenExpirationTimer);
172
174
  }
173
- if (this.apiClient?.socket && this.apiClient.socket.isConnected()) {
174
- this.apiClient.socket.disconnect();
175
- }
175
+ this.apiClient?.teardown();
176
176
  }
177
177
 
178
178
  log(message: string, force = false) {
@@ -190,6 +190,7 @@ class Knock {
190
190
  host: this.host,
191
191
  userToken: this.userToken,
192
192
  branch: this.branch,
193
+ disconnectOnPageHidden: this.disconnectOnPageHidden,
193
194
  });
194
195
  }
195
196
 
@@ -0,0 +1,70 @@
1
+ import type { Socket } from "phoenix";
2
+
3
+ const DEFAULT_DISCONNECT_DELAY_MS = 30_000;
4
+
5
+ /**
6
+ * Disconnects the socket after a delay when the page becomes hidden,
7
+ * and reconnects when it becomes visible again. This avoids holding
8
+ * open connections for background tabs that aren't being viewed.
9
+ *
10
+ * The delay prevents unnecessary disconnects during brief tab switches.
11
+ * Phoenix channels automatically rejoin after reconnecting.
12
+ */
13
+ export class PageVisibilityManager {
14
+ private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
15
+ private wasConnected = false;
16
+
17
+ constructor(
18
+ private socket: Socket,
19
+ private disconnectDelayMs: number = DEFAULT_DISCONNECT_DELAY_MS,
20
+ ) {
21
+ if (typeof document !== "undefined") {
22
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
23
+ }
24
+ }
25
+
26
+ private onVisibilityChange = () => {
27
+ if (document.hidden) {
28
+ this.scheduleDisconnect();
29
+ } else {
30
+ this.reconnect();
31
+ }
32
+ };
33
+
34
+ private scheduleDisconnect() {
35
+ this.clearTimer();
36
+
37
+ this.disconnectTimer = setTimeout(() => {
38
+ this.disconnectTimer = null;
39
+
40
+ if (this.socket.isConnected()) {
41
+ this.wasConnected = true;
42
+ this.socket.disconnect();
43
+ }
44
+ }, this.disconnectDelayMs);
45
+ }
46
+
47
+ private reconnect() {
48
+ this.clearTimer();
49
+
50
+ if (this.wasConnected) {
51
+ this.wasConnected = false;
52
+ this.socket.connect();
53
+ }
54
+ }
55
+
56
+ private clearTimer() {
57
+ if (this.disconnectTimer) {
58
+ clearTimeout(this.disconnectTimer);
59
+ this.disconnectTimer = null;
60
+ }
61
+ }
62
+
63
+ teardown() {
64
+ this.clearTimer();
65
+
66
+ if (typeof document !== "undefined") {
67
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
68
+ }
69
+ }
70
+ }