@knocklabs/client 0.21.0 → 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 (75) hide show
  1. package/CHANGELOG.md +14 -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/feed/utils.js.map +1 -1
  7. package/dist/cjs/clients/guide/client.js +1 -1
  8. package/dist/cjs/clients/guide/client.js.map +1 -1
  9. package/dist/cjs/clients/guide/helpers.js +1 -1
  10. package/dist/cjs/clients/guide/helpers.js.map +1 -1
  11. package/dist/cjs/clients/guide/types.js +2 -0
  12. package/dist/cjs/clients/guide/types.js.map +1 -0
  13. package/dist/cjs/helpers.js +1 -1
  14. package/dist/cjs/helpers.js.map +1 -1
  15. package/dist/cjs/index.js +1 -1
  16. package/dist/cjs/knock.js +2 -2
  17. package/dist/cjs/knock.js.map +1 -1
  18. package/dist/cjs/pageVisibility.js +2 -0
  19. package/dist/cjs/pageVisibility.js.map +1 -0
  20. package/dist/esm/api.mjs +27 -12
  21. package/dist/esm/api.mjs.map +1 -1
  22. package/dist/esm/clients/feed/feed.mjs +60 -87
  23. package/dist/esm/clients/feed/feed.mjs.map +1 -1
  24. package/dist/esm/clients/feed/utils.mjs.map +1 -1
  25. package/dist/esm/clients/guide/client.mjs +270 -204
  26. package/dist/esm/clients/guide/client.mjs.map +1 -1
  27. package/dist/esm/clients/guide/helpers.mjs +34 -44
  28. package/dist/esm/clients/guide/helpers.mjs.map +1 -1
  29. package/dist/esm/clients/guide/types.mjs +13 -0
  30. package/dist/esm/clients/guide/types.mjs.map +1 -0
  31. package/dist/esm/helpers.mjs +19 -4
  32. package/dist/esm/helpers.mjs.map +1 -1
  33. package/dist/esm/index.mjs +13 -10
  34. package/dist/esm/index.mjs.map +1 -1
  35. package/dist/esm/knock.mjs +31 -29
  36. package/dist/esm/knock.mjs.map +1 -1
  37. package/dist/esm/pageVisibility.mjs +31 -0
  38. package/dist/esm/pageVisibility.mjs.map +1 -0
  39. package/dist/types/api.d.ts +4 -0
  40. package/dist/types/api.d.ts.map +1 -1
  41. package/dist/types/clients/feed/feed.d.ts +1 -11
  42. package/dist/types/clients/feed/feed.d.ts.map +1 -1
  43. package/dist/types/clients/feed/interfaces.d.ts +15 -5
  44. package/dist/types/clients/feed/interfaces.d.ts.map +1 -1
  45. package/dist/types/clients/feed/utils.d.ts +0 -2
  46. package/dist/types/clients/feed/utils.d.ts.map +1 -1
  47. package/dist/types/clients/guide/client.d.ts +6 -1
  48. package/dist/types/clients/guide/client.d.ts.map +1 -1
  49. package/dist/types/clients/guide/helpers.d.ts +1 -7
  50. package/dist/types/clients/guide/helpers.d.ts.map +1 -1
  51. package/dist/types/clients/guide/index.d.ts +3 -2
  52. package/dist/types/clients/guide/index.d.ts.map +1 -1
  53. package/dist/types/clients/guide/types.d.ts +33 -1
  54. package/dist/types/clients/guide/types.d.ts.map +1 -1
  55. package/dist/types/helpers.d.ts +19 -0
  56. package/dist/types/helpers.d.ts.map +1 -1
  57. package/dist/types/interfaces.d.ts +2 -0
  58. package/dist/types/interfaces.d.ts.map +1 -1
  59. package/dist/types/knock.d.ts +1 -0
  60. package/dist/types/knock.d.ts.map +1 -1
  61. package/dist/types/pageVisibility.d.ts +22 -0
  62. package/dist/types/pageVisibility.d.ts.map +1 -0
  63. package/package.json +2 -2
  64. package/src/api.ts +30 -0
  65. package/src/clients/feed/feed.ts +0 -73
  66. package/src/clients/feed/interfaces.ts +15 -11
  67. package/src/clients/feed/utils.ts +11 -2
  68. package/src/clients/guide/client.ts +206 -84
  69. package/src/clients/guide/helpers.ts +0 -12
  70. package/src/clients/guide/index.ts +10 -1
  71. package/src/clients/guide/types.ts +55 -1
  72. package/src/helpers.ts +39 -0
  73. package/src/interfaces.ts +2 -0
  74. package/src/knock.ts +4 -3
  75. package/src/pageVisibility.ts +70 -0
@@ -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,41 +151,32 @@ 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
 
157
167
  const defaultGroup = findDefaultGroup(state.guideGroups);
158
168
  if (!defaultGroup) return result;
159
169
 
160
- const displaySequence = [...defaultGroup.display_sequence];
170
+ const displaySequence = defaultGroup.display_sequence;
161
171
  const location = state.location;
162
172
 
163
- // If in debug mode, put the forced guide at the beginning of the display sequence.
164
- if (state.debug.forcedGuideKey) {
165
- const forcedKeyIndex = displaySequence.indexOf(state.debug.forcedGuideKey);
166
- if (forcedKeyIndex > -1) {
167
- displaySequence.splice(forcedKeyIndex, 1);
168
- }
169
- displaySequence.unshift(state.debug.forcedGuideKey);
170
- }
171
-
172
173
  for (const [index, guideKey] of displaySequence.entries()) {
173
- let guide = state.guides[guideKey];
174
-
175
- // Use preview guide if it exists and matches the forced guide key
176
- if (
177
- state.debug.forcedGuideKey === guideKey &&
178
- state.previewGuides[guideKey]
179
- ) {
180
- guide = state.previewGuides[guideKey];
181
- }
182
-
174
+ const guide = state.previewGuides[guideKey] || state.guides[guideKey];
183
175
  if (!guide) continue;
184
176
 
185
- const affirmed = predicate(guide, {
177
+ const affirmed = predicate(guide, filters, {
186
178
  location,
187
- filters,
179
+ ineligibleGuides: state.ineligibleGuides,
188
180
  debug: state.debug,
189
181
  });
190
182
 
@@ -193,19 +185,20 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => {
193
185
  result.set(index, guide);
194
186
  }
195
187
 
196
- result.metadata = { guideGroup: defaultGroup };
188
+ result.metadata = { guideGroup: defaultGroup, filters, ...metadata };
189
+
197
190
  return result;
198
191
  };
199
192
 
200
- type PredicateOpts = {
201
- location?: string | undefined;
202
- filters?: SelectFilterParams | undefined;
203
- debug: DebugState;
204
- };
193
+ type PredicateOpts = Pick<
194
+ StoreState,
195
+ "location" | "ineligibleGuides" | "debug"
196
+ >;
205
197
 
206
198
  const predicate = (
207
199
  guide: KnockGuide,
208
- { location, filters = {}, debug = {} }: PredicateOpts,
200
+ filters: SelectFilterParams,
201
+ { location, ineligibleGuides = {}, debug = {} }: PredicateOpts,
209
202
  ) => {
210
203
  if (filters.type && filters.type !== guide.type) {
211
204
  return false;
@@ -215,11 +208,16 @@ const predicate = (
215
208
  return false;
216
209
  }
217
210
 
218
- // Bypass filtering if the debugged guide matches the given filters.
219
- // This should always run AFTER checking the filters but BEFORE
220
- // checking archived status and location rules.
221
- if (debug.forcedGuideKey === guide.key) {
222
- return true;
211
+ // If in debug mode with a forced guide key, bypass other filtering and always
212
+ // return true for that guide only. This should always run AFTER checking the
213
+ // filters but BEFORE checking archived status and location rules.
214
+ if (debug.forcedGuideKey) {
215
+ return debug.forcedGuideKey === guide.key;
216
+ }
217
+
218
+ const ineligible = ineligibleGuides[guide.key];
219
+ if (ineligible) {
220
+ return false;
223
221
  }
224
222
 
225
223
  if (!guide.active) {
@@ -230,6 +228,13 @@ const predicate = (
230
228
  return false;
231
229
  }
232
230
 
231
+ return checkActivatable(guide, location);
232
+ };
233
+
234
+ export const checkActivatable = (
235
+ guide: KnockGuide,
236
+ location: string | undefined,
237
+ ) => {
233
238
  const url = location ? newUrl(location) : undefined;
234
239
 
235
240
  const urlRules = guide.activation_url_rules || [];
@@ -283,18 +288,21 @@ export class KnockGuideClient {
283
288
  ) {
284
289
  const {
285
290
  trackLocationFromWindow = true,
291
+ // TODO(KNO-11523): Remove once we ship guide toolbar v2, and offload as
292
+ // much debugging specific logic and responsibilities to toolbar.
293
+ trackDebugParams = false,
286
294
  throttleCheckInterval = DEFAULT_COUNTER_INCREMENT_INTERVAL,
287
295
  } = options;
288
296
  const win = checkForWindow();
289
297
 
290
298
  const location = trackLocationFromWindow ? win?.location?.href : undefined;
291
-
292
- const debug = detectDebugParams();
299
+ const debug = trackDebugParams ? detectDebugParams() : undefined;
293
300
 
294
301
  this.store = new Store<StoreState>({
295
302
  guideGroups: [],
296
303
  guideGroupDisplayLogs: {},
297
304
  guides: {},
305
+ ineligibleGuides: {},
298
306
  previewGuides: {},
299
307
  queries: {},
300
308
  location,
@@ -376,7 +384,12 @@ export class KnockGuideClient {
376
384
  >(this.channelId, queryParams);
377
385
  queryStatus = { status: "ok" };
378
386
 
379
- const { entries, guide_groups: groups, guide_group_display_logs } = data;
387
+ const {
388
+ entries,
389
+ guide_groups: groups,
390
+ guide_group_display_logs,
391
+ ineligible_guides,
392
+ } = data;
380
393
 
381
394
  this.knock.log("[Guide] Loading fetched guides");
382
395
  this.store.setState((state) => ({
@@ -384,6 +397,7 @@ export class KnockGuideClient {
384
397
  guideGroups: groups?.length > 0 ? groups : [mockDefaultGroup(entries)],
385
398
  guideGroupDisplayLogs: guide_group_display_logs || {},
386
399
  guides: byKey(entries.map((g) => this.localCopy(g))),
400
+ ineligibleGuides: byKey(ineligible_guides || []),
387
401
  queries: { ...state.queries, [queryKey]: queryStatus },
388
402
  }));
389
403
  } catch (e) {
@@ -418,8 +432,9 @@ export class KnockGuideClient {
418
432
  const params = {
419
433
  ...this.targetParams,
420
434
  user_id: this.knock.userId,
421
- force_all_guides: debugState.forcedGuideKey ? true : undefined,
422
- preview_session_id: debugState.previewSessionId || undefined,
435
+ force_all_guides:
436
+ debugState?.forcedGuideKey || debugState?.debugging ? true : undefined,
437
+ preview_session_id: debugState?.previewSessionId || undefined,
423
438
  };
424
439
 
425
440
  const newChannel = this.socket.channel(this.socketChannelTopic, params);
@@ -484,6 +499,8 @@ export class KnockGuideClient {
484
499
  private handleSocketEvent(payload: GuideSocketEvent) {
485
500
  const { event, data } = payload;
486
501
 
502
+ // TODO(KNO-11489): Include an ineligible guide in the socket payload too
503
+ // and process it when handling socket events in real time.
487
504
  switch (event) {
488
505
  case "guide.added":
489
506
  return this.addOrReplaceGuide(payload);
@@ -565,6 +582,39 @@ export class KnockGuideClient {
565
582
  }
566
583
  }
567
584
 
585
+ setDebug(debugOpts?: Omit<DebugState, "debugging">) {
586
+ this.knock.log("[Guide] .setDebug()");
587
+ const shouldRefetch = !this.store.state.debug?.debugging;
588
+
589
+ this.store.setState((state) => ({
590
+ ...state,
591
+ debug: { ...debugOpts, debugging: true },
592
+ }));
593
+
594
+ if (shouldRefetch) {
595
+ this.knock.log(
596
+ `[Guide] Start debugging, refetching guides and resubscribing to the websocket channel`,
597
+ );
598
+ this.fetch();
599
+ this.subscribe();
600
+ }
601
+ }
602
+
603
+ unsetDebug() {
604
+ this.knock.log("[Guide] .unsetDebug()");
605
+ const shouldRefetch = this.store.state.debug?.debugging;
606
+
607
+ this.store.setState((state) => ({ ...state, debug: undefined }));
608
+
609
+ if (shouldRefetch) {
610
+ this.knock.log(
611
+ `[Guide] Stop debugging, refetching guides and resubscribing to the websocket channel`,
612
+ );
613
+ this.fetch();
614
+ this.subscribe();
615
+ }
616
+ }
617
+
568
618
  //
569
619
  // Store selector
570
620
  //
@@ -578,14 +628,35 @@ export class KnockGuideClient {
578
628
  `[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`,
579
629
  );
580
630
 
581
- const selectedGuide = this.selectGuide(state, filters, opts);
582
- 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) {
583
653
  return [];
584
654
  }
585
655
 
586
656
  // There should be at least one guide to return here now.
587
- const guides = [...select(state, filters).values()];
657
+ const guides = [...result.values()];
588
658
 
659
+ // 4. If throttled, filter out any throttled guides.
589
660
  if (!opts.includeThrottled && checkStateIfThrottled(state)) {
590
661
  const unthrottledGuides = guides.filter(
591
662
  (g) => g.bypass_global_group_limit,
@@ -618,32 +689,6 @@ export class KnockGuideClient {
618
689
  return undefined;
619
690
  }
620
691
 
621
- const result = select(state, filters);
622
-
623
- if (result.size === 0) {
624
- this.knock.log("[Guide] Selection found zero result");
625
- return undefined;
626
- }
627
-
628
- const [index, guide] = [...result][0]!;
629
- this.knock.log(
630
- `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
631
- );
632
-
633
- // If a guide ignores the group limit, then return immediately to render
634
- // always.
635
- if (guide.bypass_global_group_limit) {
636
- this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
637
- return guide;
638
- }
639
-
640
- // Check if inside the throttle window (i.e. throttled) and if so stop and
641
- // return undefined unless explicitly given the option to include throttled.
642
- if (!opts.includeThrottled && checkStateIfThrottled(state)) {
643
- this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
644
- return undefined;
645
- }
646
-
647
692
  // Starting here to the end of this method represents the core logic of how
648
693
  // "group stage" works. It provides a mechanism for 1) figuring out which
649
694
  // guide components are about to render on a page, 2) determining which
@@ -677,6 +722,35 @@ export class KnockGuideClient {
677
722
  this.stage = this.openGroupStage(); // Assign here to make tsc happy
678
723
  }
679
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
+
680
754
  switch (this.stage.status) {
681
755
  case "open": {
682
756
  this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`);
@@ -686,8 +760,16 @@ export class KnockGuideClient {
686
760
 
687
761
  case "patch": {
688
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.
689
766
  this.stage.ordered[index] = guide.key;
690
767
 
768
+ if (throttled) {
769
+ this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
770
+ return undefined;
771
+ }
772
+
691
773
  const ret = this.stage.resolved === guide.key ? guide : undefined;
692
774
  this.knock.log(
693
775
  `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -696,6 +778,11 @@ export class KnockGuideClient {
696
778
  }
697
779
 
698
780
  case "closed": {
781
+ if (throttled) {
782
+ this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
783
+ return undefined;
784
+ }
785
+
699
786
  const ret = this.stage.resolved === guide.key ? guide : undefined;
700
787
  this.knock.log(
701
788
  `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -705,6 +792,42 @@ export class KnockGuideClient {
705
792
  }
706
793
  }
707
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
+
708
831
  private openGroupStage() {
709
832
  this.knock.log("[Guide] Opening a new group stage");
710
833
 
@@ -720,6 +843,7 @@ export class KnockGuideClient {
720
843
  this.stage = {
721
844
  status: "open",
722
845
  ordered: [],
846
+ results: {},
723
847
  timeoutId,
724
848
  };
725
849
 
@@ -736,17 +860,8 @@ export class KnockGuideClient {
736
860
  // callback to a setTimeout, but just to be safe.
737
861
  this.ensureClearTimeout();
738
862
 
739
- // If in debug mode, try to resolve the forced guide, otherwise return the first non-undefined guide.
740
- let resolved = undefined;
741
- if (this.store.state.debug.forcedGuideKey) {
742
- resolved = this.stage.ordered.find(
743
- (x) => x === this.store.state.debug.forcedGuideKey,
744
- );
745
- }
746
-
747
- if (!resolved) {
748
- resolved = this.stage.ordered.find((x) => x !== undefined);
749
- }
863
+ // Resolve to the first non-undefined guide in the stage.
864
+ const resolved = this.stage.ordered.find((x) => x !== undefined);
750
865
 
751
866
  this.knock.log(
752
867
  `[Guide] Closing the current group stage: resolved=${resolved}`,
@@ -937,7 +1052,7 @@ export class KnockGuideClient {
937
1052
  // Get the next unarchived step.
938
1053
  getStep() {
939
1054
  // If debugging this guide, return the first step regardless of archive status
940
- if (self.store.state.debug.forcedGuideKey === this.key) {
1055
+ if (self.store.state.debug?.forcedGuideKey === this.key) {
941
1056
  return this.steps[0];
942
1057
  }
943
1058
 
@@ -994,7 +1109,7 @@ export class KnockGuideClient {
994
1109
 
995
1110
  // Append debug params
996
1111
  const debugState = this.store.state.debug;
997
- if (debugState.forcedGuideKey) {
1112
+ if (debugState?.forcedGuideKey || debugState?.debugging) {
998
1113
  combinedParams.force_all_guides = true;
999
1114
  }
1000
1115
 
@@ -1163,8 +1278,15 @@ export class KnockGuideClient {
1163
1278
 
1164
1279
  this.knock.log(`[Guide] Detected a location change: ${href}`);
1165
1280
 
1281
+ if (!this.options.trackDebugParams) {
1282
+ this.setLocation(href);
1283
+ return;
1284
+ }
1285
+
1286
+ // TODO(KNO-11523): Remove below once we ship toolbar v2.
1287
+
1166
1288
  // If entering debug mode, fetch all guides.
1167
- const currentDebugParams = this.store.state.debug;
1289
+ const currentDebugParams = this.store.state.debug || {};
1168
1290
  const newDebugParams = detectDebugParams();
1169
1291
 
1170
1292
  this.setLocation(href, { debug: newDebugParams });
@@ -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
  };
@@ -1,9 +1,18 @@
1
- export { KnockGuideClient, DEBUG_QUERY_PARAMS } from "./client";
1
+ export {
2
+ KnockGuideClient,
3
+ DEBUG_QUERY_PARAMS,
4
+ checkActivatable,
5
+ } from "./client";
6
+ export { checkStateIfThrottled } from "./helpers";
2
7
  export type {
3
8
  KnockGuide,
4
9
  KnockGuideStep,
10
+ GuideIneligibilityMarker as KnockGuideIneligibilityMarker,
5
11
  TargetParams as KnockGuideTargetParams,
6
12
  SelectFilterParams as KnockGuideFilterParams,
7
13
  SelectGuideOpts as KnockSelectGuideOpts,
8
14
  SelectGuidesOpts as KnockSelectGuidesOpts,
15
+ StoreState as KnockGuideClientStoreState,
16
+ GroupStage as KnockGuideClientGroupStage,
17
+ SelectionResult as KnockGuideSelectionResult,
9
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
  //
@@ -65,6 +87,19 @@ export interface GuideGroupData {
65
87
  updated_at: string;
66
88
  }
67
89
 
90
+ type GuideIneligibilityReason =
91
+ | "guide_not_active"
92
+ | "marked_as_archived"
93
+ | "target_conditions_not_met"
94
+ | "not_in_target_audience";
95
+
96
+ export type GuideIneligibilityMarker = {
97
+ __typename: "GuideIneligibilityMarker";
98
+ key: KnockGuide["key"];
99
+ reason: GuideIneligibilityReason;
100
+ message: string;
101
+ };
102
+
68
103
  export type GetGuidesQueryParams = {
69
104
  data?: string;
70
105
  tenant?: string;
@@ -76,6 +111,7 @@ export type GetGuidesResponse = {
76
111
  entries: GuideData[];
77
112
  guide_groups: GuideGroupData[];
78
113
  guide_group_display_logs: Record<GuideGroupData["key"], string>;
114
+ ineligible_guides: GuideIneligibilityMarker[];
79
115
  };
80
116
 
81
117
  //
@@ -194,6 +230,7 @@ export type QueryStatus = {
194
230
  };
195
231
 
196
232
  export type DebugState = {
233
+ debugging?: boolean;
197
234
  forcedGuideKey?: string | null;
198
235
  previewSessionId?: string | null;
199
236
  };
@@ -202,11 +239,15 @@ export type StoreState = {
202
239
  guideGroups: GuideGroupData[];
203
240
  guideGroupDisplayLogs: Record<GuideGroupData["key"], string>;
204
241
  guides: Record<KnockGuide["key"], KnockGuide>;
242
+ ineligibleGuides: Record<
243
+ GuideIneligibilityMarker["key"],
244
+ GuideIneligibilityMarker
245
+ >;
205
246
  previewGuides: Record<KnockGuide["key"], KnockGuide>;
206
247
  queries: Record<QueryKey, QueryStatus>;
207
248
  location: string | undefined;
208
249
  counter: number;
209
- debug: DebugState;
250
+ debug?: DebugState;
210
251
  };
211
252
 
212
253
  export type QueryFilterParams = Pick<GetGuidesQueryParams, "type">;
@@ -218,6 +259,7 @@ export type SelectFilterParams = {
218
259
 
219
260
  export type SelectGuideOpts = {
220
261
  includeThrottled?: boolean;
262
+ recordSelectQuery?: boolean;
221
263
  };
222
264
 
223
265
  export type SelectGuidesOpts = SelectGuideOpts;
@@ -229,13 +271,25 @@ export type TargetParams = {
229
271
 
230
272
  export type ConstructorOpts = {
231
273
  trackLocationFromWindow?: boolean;
274
+ trackDebugParams?: boolean;
232
275
  orderResolutionDuration?: number;
233
276
  throttleCheckInterval?: number;
234
277
  };
235
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
+
236
289
  export type GroupStage = {
237
290
  status: "open" | "closed" | "patch";
238
291
  ordered: Array<KnockGuide["key"]>;
239
292
  resolved?: KnockGuide["key"];
240
293
  timeoutId: ReturnType<typeof setTimeout> | null;
294
+ results: RecordedSelectionResults;
241
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> {