@knocklabs/client 0.21.1 → 0.21.3

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 +20 -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 +346 -268
  24. package/dist/esm/clients/guide/client.mjs.map +1 -1
  25. package/dist/esm/clients/guide/helpers.mjs +50 -57
  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 +4 -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 +24 -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 +182 -35
  66. package/src/clients/guide/helpers.ts +4 -12
  67. package/src/clients/guide/index.ts +3 -0
  68. package/src/clients/guide/types.ts +37 -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
@@ -15,6 +15,7 @@ declare class Knock {
15
15
  userToken?: string;
16
16
  logLevel?: LogLevel;
17
17
  readonly branch?: string;
18
+ private readonly disconnectOnPageHidden?;
18
19
  private tokenExpirationTimer;
19
20
  readonly feeds: FeedClient;
20
21
  readonly objects: ObjectClient;
@@ -1 +1 @@
1
- {"version":3,"file":"knock.d.ts","sourceRoot":"","sources":["../../src/knock.ts"],"names":[],"mappings":"AAEA,OAAO,SAAS,MAAM,OAAO,CAAC;AAC9B,OAAO,UAAU,MAAM,gBAAgB,CAAC;AACxC,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,YAAY,MAAM,mBAAmB,CAAC;AAC7C,OAAO,WAAW,MAAM,uBAAuB,CAAC;AAChD,OAAO,WAAW,MAAM,iBAAiB,CAAC;AAC1C,OAAO,UAAU,MAAM,iBAAiB,CAAC;AACzC,OAAO,EACL,mBAAmB,EACnB,YAAY,EACZ,QAAQ,EACR,MAAM,EACN,0BAA0B,EAE3B,MAAM,cAAc,CAAC;AAItB,cAAM,KAAK;IAiBP,QAAQ,CAAC,MAAM,EAAE,MAAM;IAhBlB,IAAI,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,SAAS,CAA0B;IACpC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC3B,SAAgB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChC,OAAO,CAAC,oBAAoB,CAA8C;IAC1E,QAAQ,CAAC,KAAK,aAAwB;IACtC,QAAQ,CAAC,OAAO,eAA0B;IAC1C,QAAQ,CAAC,WAAW,cAAyB;IAC7C,QAAQ,CAAC,KAAK,cAAyB;IACvC,QAAQ,CAAC,OAAO,gBAA2B;IAC3C,QAAQ,CAAC,IAAI,aAAwB;IACrC,QAAQ,CAAC,QAAQ,gBAA2B;gBAGjC,MAAM,EAAE,MAAM,EACvB,OAAO,GAAE,YAAiB;IAgB5B,MAAM;IASN;;;;;;;OAOG;IACH,YAAY,CACV,0BAA0B,EAAE,MAAM,EAClC,SAAS,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,EAC9B,OAAO,CAAC,EAAE,mBAAmB,GAC5B,KAAK;IACR,YAAY,CACV,0BAA0B,EAAE,0BAA0B,EACtD,SAAS,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,EAC9B,OAAO,CAAC,EAAE,mBAAmB,GAC5B,IAAI;IAwEP,sBAAsB;IAUtB,eAAe,CAAC,cAAc,UAAQ;IAKtC,QAAQ;IASR,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,UAAQ;IAMlC;;OAEG;IACH,OAAO,CAAC,eAAe;YAST,gCAAgC;IA+B9C;;;;;OAKG;IACH,OAAO,CAAC,SAAS;CAclB;AAED,eAAe,KAAK,CAAC"}
1
+ {"version":3,"file":"knock.d.ts","sourceRoot":"","sources":["../../src/knock.ts"],"names":[],"mappings":"AAEA,OAAO,SAAS,MAAM,OAAO,CAAC;AAC9B,OAAO,UAAU,MAAM,gBAAgB,CAAC;AACxC,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAC/C,OAAO,YAAY,MAAM,mBAAmB,CAAC;AAC7C,OAAO,WAAW,MAAM,uBAAuB,CAAC;AAChD,OAAO,WAAW,MAAM,iBAAiB,CAAC;AAC1C,OAAO,UAAU,MAAM,iBAAiB,CAAC;AACzC,OAAO,EACL,mBAAmB,EACnB,YAAY,EACZ,QAAQ,EACR,MAAM,EACN,0BAA0B,EAE3B,MAAM,cAAc,CAAC;AAItB,cAAM,KAAK;IAkBP,QAAQ,CAAC,MAAM,EAAE,MAAM;IAjBlB,IAAI,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,SAAS,CAA0B;IACpC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC3B,SAAgB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAU;IAClD,OAAO,CAAC,oBAAoB,CAA8C;IAC1E,QAAQ,CAAC,KAAK,aAAwB;IACtC,QAAQ,CAAC,OAAO,eAA0B;IAC1C,QAAQ,CAAC,WAAW,cAAyB;IAC7C,QAAQ,CAAC,KAAK,cAAyB;IACvC,QAAQ,CAAC,OAAO,gBAA2B;IAC3C,QAAQ,CAAC,IAAI,aAAwB;IACrC,QAAQ,CAAC,QAAQ,gBAA2B;gBAGjC,MAAM,EAAE,MAAM,EACvB,OAAO,GAAE,YAAiB;IAiB5B,MAAM;IASN;;;;;;;OAOG;IACH,YAAY,CACV,0BAA0B,EAAE,MAAM,EAClC,SAAS,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,EAC9B,OAAO,CAAC,EAAE,mBAAmB,GAC5B,KAAK;IACR,YAAY,CACV,0BAA0B,EAAE,0BAA0B,EACtD,SAAS,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,EAC9B,OAAO,CAAC,EAAE,mBAAmB,GAC5B,IAAI;IAwEP,sBAAsB;IAUtB,eAAe,CAAC,cAAc,UAAQ;IAKtC,QAAQ;IAOR,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,UAAQ;IAMlC;;OAEG;IACH,OAAO,CAAC,eAAe;YAUT,gCAAgC;IA+B9C;;;;;OAKG;IACH,OAAO,CAAC,SAAS;CAclB;AAED,eAAe,KAAK,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { Socket } from 'phoenix';
2
+ /**
3
+ * Disconnects the socket after a delay when the page becomes hidden,
4
+ * and reconnects when it becomes visible again. This avoids holding
5
+ * open connections for background tabs that aren't being viewed.
6
+ *
7
+ * The delay prevents unnecessary disconnects during brief tab switches.
8
+ * Phoenix channels automatically rejoin after reconnecting.
9
+ */
10
+ export declare class PageVisibilityManager {
11
+ private socket;
12
+ private disconnectDelayMs;
13
+ private disconnectTimer;
14
+ private wasConnected;
15
+ constructor(socket: Socket, disconnectDelayMs?: number);
16
+ private onVisibilityChange;
17
+ private scheduleDisconnect;
18
+ private reconnect;
19
+ private clearTimer;
20
+ teardown(): void;
21
+ }
22
+ //# sourceMappingURL=pageVisibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pageVisibility.d.ts","sourceRoot":"","sources":["../../src/pageVisibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAItC;;;;;;;GAOG;AACH,qBAAa,qBAAqB;IAK9B,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,iBAAiB;IAL3B,OAAO,CAAC,eAAe,CAA8C;IACrE,OAAO,CAAC,YAAY,CAAS;gBAGnB,MAAM,EAAE,MAAM,EACd,iBAAiB,GAAE,MAAoC;IAOjE,OAAO,CAAC,kBAAkB,CAMxB;IAEF,OAAO,CAAC,kBAAkB;IAa1B,OAAO,CAAC,SAAS;IASjB,OAAO,CAAC,UAAU;IAOlB,QAAQ;CAOT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knocklabs/client",
3
- "version": "0.21.1",
3
+ "version": "0.21.3",
4
4
  "description": "The clientside library for interacting with Knock",
5
5
  "homepage": "https://github.com/knocklabs/javascript/tree/main/packages/client",
6
6
  "author": "@knocklabs",
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
 
@@ -200,6 +211,12 @@ const predicate = (
200
211
  // If in debug mode with a forced guide key, bypass other filtering and always
201
212
  // return true for that guide only. This should always run AFTER checking the
202
213
  // filters but BEFORE checking archived status and location rules.
214
+ if (
215
+ debug.focusedGuideKeys &&
216
+ Object.keys(debug.focusedGuideKeys).length > 0
217
+ ) {
218
+ return !!debug.focusedGuideKeys[guide.key];
219
+ }
203
220
  if (debug.forcedGuideKey) {
204
221
  return debug.forcedGuideKey === guide.key;
205
222
  }
@@ -552,7 +569,11 @@ export class KnockGuideClient {
552
569
  // Clear debug state from store
553
570
  this.store.setState((state) => ({
554
571
  ...state,
555
- debug: { forcedGuideKey: null, previewSessionId: null },
572
+ debug: {
573
+ forcedGuideKey: null,
574
+ previewSessionId: null,
575
+ focusedGuideKeys: {},
576
+ },
556
577
  previewGuides: {}, // Clear preview guides when exiting debug mode
557
578
  }));
558
579
 
@@ -577,7 +598,13 @@ export class KnockGuideClient {
577
598
 
578
599
  this.store.setState((state) => ({
579
600
  ...state,
580
- debug: { ...debugOpts, debugging: true },
601
+ debug: {
602
+ skipEngagementTracking: true,
603
+ ignoreDisplayInterval: true,
604
+ focusedGuideKeys: {},
605
+ ...debugOpts,
606
+ debugging: true,
607
+ },
581
608
  }));
582
609
 
583
610
  if (shouldRefetch) {
@@ -617,14 +644,35 @@ export class KnockGuideClient {
617
644
  `[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`,
618
645
  );
619
646
 
620
- const selectedGuide = this.selectGuide(state, filters, opts);
621
- if (!selectedGuide) {
647
+ // 1. First, call selectGuide() using the same filters to ensure we have a
648
+ // group stage open and respect throttling. This isn't the real query, but
649
+ // rather it's a shortcut ahead of handling the actual query result below.
650
+ const selectedGuide = this.selectGuide(state, filters, {
651
+ ...opts,
652
+ // Don't record this result, not the actual query result we need.
653
+ recordSelectQuery: false,
654
+ });
655
+
656
+ // 2. Now make the actual select query with the provided filters and opts,
657
+ // and record the result (as needed). By default, we only record the result
658
+ // while in debugging.
659
+ const { recordSelectQuery = !!state.debug?.debugging } = opts;
660
+ const metadata: SelectQueryMetadata = {
661
+ limit: "all",
662
+ opts: { ...opts, recordSelectQuery },
663
+ };
664
+ const result = select(state, filters, metadata);
665
+ this.maybeRecordSelectResult(result);
666
+
667
+ // 3. Stop if there is not at least one guide to return.
668
+ if (!selectedGuide && !opts.includeThrottled) {
622
669
  return [];
623
670
  }
624
671
 
625
672
  // There should be at least one guide to return here now.
626
- const guides = [...select(state, filters).values()];
673
+ const guides = [...result.values()];
627
674
 
675
+ // 4. If throttled, filter out any throttled guides.
628
676
  if (!opts.includeThrottled && checkStateIfThrottled(state)) {
629
677
  const unthrottledGuides = guides.filter(
630
678
  (g) => g.bypass_global_group_limit,
@@ -657,32 +705,6 @@ export class KnockGuideClient {
657
705
  return undefined;
658
706
  }
659
707
 
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
708
  // Starting here to the end of this method represents the core logic of how
687
709
  // "group stage" works. It provides a mechanism for 1) figuring out which
688
710
  // guide components are about to render on a page, 2) determining which
@@ -716,6 +738,39 @@ export class KnockGuideClient {
716
738
  this.stage = this.openGroupStage(); // Assign here to make tsc happy
717
739
  }
718
740
 
741
+ // Must come AFTER we ensure a group stage exists above, so we can record
742
+ // select queries. By default, we only record the result while in debugging.
743
+ const { recordSelectQuery = !!state.debug?.debugging } = opts;
744
+ const metadata: SelectQueryMetadata = {
745
+ limit: "one",
746
+ opts: { ...opts, recordSelectQuery },
747
+ };
748
+ const result = select(state, filters, metadata);
749
+ this.maybeRecordSelectResult(result);
750
+
751
+ if (result.size === 0) {
752
+ this.knock.log("[Guide] Selection found zero result");
753
+ return undefined;
754
+ }
755
+
756
+ const [index, guide] = [...result][0]!;
757
+ this.knock.log(
758
+ `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
759
+ );
760
+
761
+ // If a guide ignores the group limit, then return immediately to render
762
+ // always.
763
+ if (guide.bypass_global_group_limit) {
764
+ this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
765
+ return guide;
766
+ }
767
+
768
+ // If focused while in debug mode, then we want to ignore the guide order
769
+ // and throttle settings and force render this guide.
770
+ const focusedInDebug = state.debug?.focusedGuideKeys?.[guide.key];
771
+
772
+ const throttled = !opts.includeThrottled && checkStateIfThrottled(state);
773
+
719
774
  switch (this.stage.status) {
720
775
  case "open": {
721
776
  this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`);
@@ -725,8 +780,23 @@ export class KnockGuideClient {
725
780
 
726
781
  case "patch": {
727
782
  this.knock.log(`[Guide] Patching the group stage: ${guide.key}`);
783
+ // Refresh the ordered queue in the group stage while continuing to
784
+ // render the currently resolved guide while in patch window, so that
785
+ // we can re-resolve when the group stage closes.
728
786
  this.stage.ordered[index] = guide.key;
729
787
 
788
+ if (focusedInDebug) {
789
+ this.knock.log(
790
+ `[Guide] Focused to return \`${guide.key}\` (stage: ${formatGroupStage(this.stage)})`,
791
+ );
792
+ return guide;
793
+ }
794
+
795
+ if (throttled) {
796
+ this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
797
+ return undefined;
798
+ }
799
+
730
800
  const ret = this.stage.resolved === guide.key ? guide : undefined;
731
801
  this.knock.log(
732
802
  `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -735,6 +805,18 @@ export class KnockGuideClient {
735
805
  }
736
806
 
737
807
  case "closed": {
808
+ if (focusedInDebug) {
809
+ this.knock.log(
810
+ `[Guide] Focused to return \`${guide.key}\` (stage: ${formatGroupStage(this.stage)})`,
811
+ );
812
+ return guide;
813
+ }
814
+
815
+ if (throttled) {
816
+ this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
817
+ return undefined;
818
+ }
819
+
738
820
  const ret = this.stage.resolved === guide.key ? guide : undefined;
739
821
  this.knock.log(
740
822
  `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -744,6 +826,42 @@ export class KnockGuideClient {
744
826
  }
745
827
  }
746
828
 
829
+ // Record select query results by accumulating them by 1) key or type first,
830
+ // and then 2) "one" or "all".
831
+ private maybeRecordSelectResult(result: SelectionResult) {
832
+ if (!result.metadata) return;
833
+
834
+ const { opts, filters, limit } = result.metadata;
835
+ if (!opts.recordSelectQuery) return;
836
+ if (!filters.key && !filters.type) return;
837
+ if (!this.stage || this.stage.status === "closed") return;
838
+
839
+ // Deep merge to accumulate the results.
840
+ const queriedByKey = this.stage.results.key || {};
841
+ if (filters.key) {
842
+ queriedByKey[filters.key] = {
843
+ ...(queriedByKey[filters.key] || {}),
844
+ ...{ [limit]: result },
845
+ };
846
+ }
847
+ const queriedByType = this.stage.results.type || {};
848
+ if (filters.type) {
849
+ queriedByType[filters.type] = {
850
+ ...(queriedByType[filters.type] || {}),
851
+ ...{ [limit]: result },
852
+ };
853
+ }
854
+
855
+ this.stage = {
856
+ ...this.stage,
857
+ results: { key: queriedByKey, type: queriedByType },
858
+ };
859
+ }
860
+
861
+ getStage() {
862
+ return this.stage;
863
+ }
864
+
747
865
  private openGroupStage() {
748
866
  this.knock.log("[Guide] Opening a new group stage");
749
867
 
@@ -759,6 +877,7 @@ export class KnockGuideClient {
759
877
  this.stage = {
760
878
  status: "open",
761
879
  ordered: [],
880
+ results: {},
762
881
  timeoutId,
763
882
  };
764
883
 
@@ -885,6 +1004,13 @@ export class KnockGuideClient {
885
1004
  });
886
1005
  if (!updatedStep) return;
887
1006
 
1007
+ if (this.shouldSkipEngagementApi()) {
1008
+ this.knock.log(
1009
+ "[Guide] Skipping engagement API call for markAsSeen (debug mode)",
1010
+ );
1011
+ return updatedStep;
1012
+ }
1013
+
888
1014
  const params = {
889
1015
  ...this.buildEngagementEventBaseParams(guide, updatedStep),
890
1016
  content: updatedStep.content,
@@ -915,6 +1041,13 @@ export class KnockGuideClient {
915
1041
  });
916
1042
  if (!updatedStep) return;
917
1043
 
1044
+ if (this.shouldSkipEngagementApi()) {
1045
+ this.knock.log(
1046
+ "[Guide] Skipping engagement API call for markAsInteracted (debug mode)",
1047
+ );
1048
+ return updatedStep;
1049
+ }
1050
+
918
1051
  const params = {
919
1052
  ...this.buildEngagementEventBaseParams(guide, updatedStep),
920
1053
  metadata,
@@ -940,6 +1073,13 @@ export class KnockGuideClient {
940
1073
  });
941
1074
  if (!updatedStep) return;
942
1075
 
1076
+ if (this.shouldSkipEngagementApi()) {
1077
+ this.knock.log(
1078
+ "[Guide] Skipping engagement API call for markAsArchived (debug mode)",
1079
+ );
1080
+ return updatedStep;
1081
+ }
1082
+
943
1083
  const params = this.buildEngagementEventBaseParams(guide, updatedStep);
944
1084
 
945
1085
  this.knock.user.markGuideStepAs<MarkAsArchivedParams, MarkGuideAsResponse>(
@@ -953,6 +1093,10 @@ export class KnockGuideClient {
953
1093
  return updatedStep;
954
1094
  }
955
1095
 
1096
+ private shouldSkipEngagementApi(): boolean {
1097
+ return !!this.store.state.debug?.skipEngagementTracking;
1098
+ }
1099
+
956
1100
  //
957
1101
  // Helpers
958
1102
  //
@@ -967,7 +1111,10 @@ export class KnockGuideClient {
967
1111
  // Get the next unarchived step.
968
1112
  getStep() {
969
1113
  // If debugging this guide, return the first step regardless of archive status
970
- if (self.store.state.debug?.forcedGuideKey === this.key) {
1114
+ if (
1115
+ self.store.state.debug?.forcedGuideKey === this.key ||
1116
+ self.store.state.debug?.focusedGuideKeys?.[this.key]
1117
+ ) {
971
1118
  return this.steps[0];
972
1119
  }
973
1120
 
@@ -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
  };
@@ -76,6 +64,10 @@ export const findDefaultGroup = (guideGroups: GuideGroupData[]) =>
76
64
  );
77
65
 
78
66
  export const checkStateIfThrottled = (state: StoreState) => {
67
+ if (state.debug?.ignoreDisplayInterval) {
68
+ return false;
69
+ }
70
+
79
71
  const defaultGroup = findDefaultGroup(state.guideGroups);
80
72
  const throttleWindowStartedAt =
81
73
  state.guideGroupDisplayLogs[DEFAULT_GROUP_KEY];
@@ -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";