@wireai/activation 0.14.3 → 0.16.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.
Files changed (85) hide show
  1. package/AGENTS.md +96 -21
  2. package/CHANGELOG.md +780 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +110 -31
  5. package/dist/analytics/index.d.mts +35 -13
  6. package/dist/analytics/index.d.ts +35 -13
  7. package/dist/analytics/index.js +288 -127
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +288 -127
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +15 -1
  12. package/dist/coachmarks/index.d.ts +15 -1
  13. package/dist/coachmarks/index.js +120 -22
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +120 -22
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
  18. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
  19. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  20. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.js +125 -36
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +125 -36
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/questionnaire/index.d.mts +3 -16
  28. package/dist/questionnaire/index.d.ts +3 -16
  29. package/dist/questionnaire/index.js +202 -46
  30. package/dist/questionnaire/index.js.map +1 -1
  31. package/dist/questionnaire/index.mjs +203 -47
  32. package/dist/questionnaire/index.mjs.map +1 -1
  33. package/dist/reviews/index.d.mts +10 -6
  34. package/dist/reviews/index.d.ts +10 -6
  35. package/dist/reviews/index.js +269 -52
  36. package/dist/reviews/index.js.map +1 -1
  37. package/dist/reviews/index.mjs +270 -53
  38. package/dist/reviews/index.mjs.map +1 -1
  39. package/dist/showcase/index.d.mts +1 -1
  40. package/dist/showcase/index.d.ts +1 -1
  41. package/dist/showcase/index.js +106 -20
  42. package/dist/showcase/index.js.map +1 -1
  43. package/dist/showcase/index.mjs +107 -21
  44. package/dist/showcase/index.mjs.map +1 -1
  45. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  46. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  47. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  48. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  49. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  50. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  51. package/llms.txt +9 -9
  52. package/package.json +6 -9
  53. package/src/WireOnboarding.tsx +4 -1
  54. package/src/analytics/currentSession.ts +141 -4
  55. package/src/analytics/index.ts +6 -1
  56. package/src/analytics/reportClientEvent.ts +19 -10
  57. package/src/analytics/useAnalytics.ts +74 -15
  58. package/src/analytics/wireDoctor.ts +152 -7
  59. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  60. package/src/coachmarks/runtime.ts +156 -2
  61. package/src/coachmarks/types.ts +7 -2
  62. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  63. package/src/context/deviceId.ts +72 -6
  64. package/src/features/WireFeaturesProvider.tsx +72 -12
  65. package/src/features/fetchWireFeatures.ts +49 -11
  66. package/src/features/useWireFeatures.ts +39 -3
  67. package/src/identity/identityRecord.ts +15 -2
  68. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  69. package/src/questionnaire/transport.ts +22 -8
  70. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  71. package/src/reviews/ReviewGate.tsx +125 -37
  72. package/src/reviews/decision.ts +11 -1
  73. package/src/reviews/idempotency.ts +109 -0
  74. package/src/reviews/index.ts +4 -2
  75. package/src/reviews/runtime.ts +44 -13
  76. package/src/reviews/transport.ts +39 -20
  77. package/src/reviews/types.ts +7 -0
  78. package/src/reviews/useReviewGate.ts +57 -7
  79. package/src/session-analytics/lifecycle.ts +16 -0
  80. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  81. package/src/session-analytics/useSessionStart.ts +22 -2
  82. package/src/showcase/FeatureShowcase.tsx +50 -3
  83. package/src/types.ts +15 -8
  84. package/src/utils/submitResult.ts +17 -9
  85. package/src/utils/withDeadline.ts +70 -0
@@ -1,11 +1,11 @@
1
1
  import React__default from 'react';
2
- import { R as ReviewGateProps, a as ReviewPresentation, U as UseReviewGateOptions, b as ReviewGateController, c as ReviewDecision, d as ReviewStoreConfig } from '../types-Cju-1_jT.mjs';
3
- export { e as RatingRoute, f as ReviewConfig, g as ReviewDecisionResponse, h as ReviewGateEvent, i as ReviewGateEventName, j as ReviewSubmission, k as ReviewTarget, S as SubmitResult } from '../types-Cju-1_jT.mjs';
2
+ import { R as ReviewGateProps, a as ReviewPresentation, U as UseReviewGateOptions, b as ReviewGateController, c as ReviewDecision, d as ReviewStoreConfig } from '../types-Byx306Kv.mjs';
3
+ export { e as RatingRoute, f as ReviewConfig, g as ReviewDecisionResponse, h as ReviewGateEvent, i as ReviewGateEventName, j as ReviewSubmission, k as ReviewTarget, S as SubmitResult } from '../types-Byx306Kv.mjs';
4
4
  export { C as ReviewModal, a as ReviewModalHandle, b as ReviewModalProps } from '../CenteredModal-C3qQBHsA.mjs';
5
- export { G as GateRules, a as GateSignals, b as buildReviewSubmission, d as decideReview, e as evaluateGate, r as resolveRules, c as routeRating } from '../decision-Bgo17oH7.mjs';
5
+ export { G as GateRules, a as GateSignals, b as buildReviewSubmission, d as decideReview, e as evaluateGate, r as resolveRules, c as routeRating } from '../decision-yBj2AyPW.mjs';
6
6
  import { c as OnboardingFonts } from '../types-BKfpdZzX.mjs';
7
- export { F as FetchReviewDecisionOptions, R as ReportAppEventOptions, a as ReviewSubmitResult, f as fetchReviewDecision, r as reportAppEvent, s as submitReview } from '../transport-j5gFfJhK.mjs';
8
- import '../types-BcmagF6K.mjs';
7
+ export { F as FetchReviewDecisionOptions, R as ReportAppEventOptions, a as ReviewSubmitResult, f as fetchReviewDecision, r as reportAppEvent, s as submitReview } from '../transport-s5QxA-ci.mjs';
8
+ import '../types-D_0B0yay.mjs';
9
9
  import '../types-BpwiRpA8.mjs';
10
10
 
11
11
  /**
@@ -41,7 +41,11 @@ declare const reviewSessionsKey: (id: string) => string;
41
41
  *
42
42
  * The form NEVER asks for identity (no name / email / contact field): the host already
43
43
  * identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
44
- * the POST body carries alongside the rating and the free text.
44
+ * the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
45
+ * fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
46
+ * identification, but because it is the unit the server scopes the idempotency key to, and without
47
+ * it the key is discarded. Any non-blank string the host passes still wins verbatim. See
48
+ * `idempotency.ts`.
45
49
  *
46
50
  * Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
47
51
  * ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
@@ -1,11 +1,11 @@
1
1
  import React__default from 'react';
2
- import { R as ReviewGateProps, a as ReviewPresentation, U as UseReviewGateOptions, b as ReviewGateController, c as ReviewDecision, d as ReviewStoreConfig } from '../types-h2BZvl1t.js';
3
- export { e as RatingRoute, f as ReviewConfig, g as ReviewDecisionResponse, h as ReviewGateEvent, i as ReviewGateEventName, j as ReviewSubmission, k as ReviewTarget, S as SubmitResult } from '../types-h2BZvl1t.js';
2
+ import { R as ReviewGateProps, a as ReviewPresentation, U as UseReviewGateOptions, b as ReviewGateController, c as ReviewDecision, d as ReviewStoreConfig } from '../types-tdATL5z0.js';
3
+ export { e as RatingRoute, f as ReviewConfig, g as ReviewDecisionResponse, h as ReviewGateEvent, i as ReviewGateEventName, j as ReviewSubmission, k as ReviewTarget, S as SubmitResult } from '../types-tdATL5z0.js';
4
4
  export { C as ReviewModal, a as ReviewModalHandle, b as ReviewModalProps } from '../CenteredModal-Cdgns6--.js';
5
- export { G as GateRules, a as GateSignals, b as buildReviewSubmission, d as decideReview, e as evaluateGate, r as resolveRules, c as routeRating } from '../decision-Bkh_LigV.js';
5
+ export { G as GateRules, a as GateSignals, b as buildReviewSubmission, d as decideReview, e as evaluateGate, r as resolveRules, c as routeRating } from '../decision-3vWLuBlO.js';
6
6
  import { c as OnboardingFonts } from '../types-BKfpdZzX.js';
7
- export { F as FetchReviewDecisionOptions, R as ReportAppEventOptions, a as ReviewSubmitResult, f as fetchReviewDecision, r as reportAppEvent, s as submitReview } from '../transport-B_0SgCBe.js';
8
- import '../types-BcmagF6K.js';
7
+ export { F as FetchReviewDecisionOptions, R as ReportAppEventOptions, a as ReviewSubmitResult, f as fetchReviewDecision, r as reportAppEvent, s as submitReview } from '../transport-xqqSFqxs.js';
8
+ import '../types-D_0B0yay.js';
9
9
  import '../types-BpwiRpA8.js';
10
10
 
11
11
  /**
@@ -41,7 +41,11 @@ declare const reviewSessionsKey: (id: string) => string;
41
41
  *
42
42
  * The form NEVER asks for identity (no name / email / contact field): the host already
43
43
  * identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
44
- * the POST body carries alongside the rating and the free text.
44
+ * the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
45
+ * fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
46
+ * identification, but because it is the unit the server scopes the idempotency key to, and without
47
+ * it the key is discarded. Any non-blank string the host passes still wins verbatim. See
48
+ * `idempotency.ts`.
45
49
  *
46
50
  * Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
47
51
  * ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
@@ -546,6 +546,7 @@ var buildReviewSubmission = (input) => {
546
546
  if (contact) body.contact = contact;
547
547
  if (input.sessionId) body.session_id = input.sessionId;
548
548
  if (input.source) body.source = input.source;
549
+ if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
549
550
  if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
550
551
  return body;
551
552
  };
@@ -563,6 +564,43 @@ var feedbackInputHeights = (fonts, verticalPadding = 0) => {
563
564
  };
564
565
  };
565
566
 
567
+ // src/context/deviceId.ts
568
+ var AUTO_DEVICE_ID_PREFIX = "wdev_";
569
+ var randomChunk = () => Math.floor(Math.random() * 4294967296).toString(36).padStart(6, "0");
570
+ var mintDeviceId = () => {
571
+ const time = Date.now().toString(36);
572
+ return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
573
+ };
574
+ var AUTO_DEVICE_KEY_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:autoDeviceKeys");
575
+ var deviceKeyGlobal = globalThis;
576
+ var autoDeviceKeyRegistry = () => {
577
+ const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
578
+ if (existing) return existing;
579
+ const created = { keys: /* @__PURE__ */ new Map(), hydrating: /* @__PURE__ */ new Set() };
580
+ deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
581
+ return created;
582
+ };
583
+ var ambientAutoDeviceKey = () => {
584
+ const registry = autoDeviceKeyRegistry();
585
+ if (registry.keys.size > 1) return void 0;
586
+ const sole = [...registry.keys.values()][0];
587
+ if (sole) return sole;
588
+ if (!registry.ambient) registry.ambient = mintDeviceId();
589
+ return registry.ambient;
590
+ };
591
+
592
+ // src/reviews/idempotency.ts
593
+ var REVIEW_IDEMPOTENCY_PREFIX = "rvw_";
594
+ var mintIdempotencyKey = () => `${REVIEW_IDEMPOTENCY_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
595
+ var hasDeviceKey = (meta) => {
596
+ const value = meta == null ? void 0 : meta.device_key;
597
+ return typeof value === "string" && value.trim().length > 0;
598
+ };
599
+ var createImpression = () => ({
600
+ idempotencyKey: mintIdempotencyKey(),
601
+ deviceKey: ambientAutoDeviceKey()
602
+ });
603
+
566
604
  // src/motion/centeredModalMotion.ts
567
605
  var CENTERED_MODAL_ANIM = {
568
606
  /** Spring config for the card scale/opacity entrance (mirrors CompletionView's pop). */
@@ -740,6 +778,28 @@ var requestStoreReview = async (store) => {
740
778
  return opened ? "store_url" : "none";
741
779
  };
742
780
 
781
+ // src/utils/withDeadline.ts
782
+ var DEADLINE_EXPIRED = /* @__PURE__ */ Symbol("wire-deadline-expired");
783
+ var DEFAULT_DEADLINE_MS = 15e3;
784
+ var withDeadline = async (exchange, timeoutMs = DEFAULT_DEADLINE_MS) => {
785
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
786
+ let timer;
787
+ const expired = new Promise((resolve) => {
788
+ timer = setTimeout(() => {
789
+ controller == null ? void 0 : controller.abort();
790
+ resolve(DEADLINE_EXPIRED);
791
+ }, timeoutMs);
792
+ });
793
+ const running = exchange(controller == null ? void 0 : controller.signal);
794
+ try {
795
+ return await Promise.race([running, expired]);
796
+ } finally {
797
+ clearTimeout(timer);
798
+ void running.catch(() => {
799
+ });
800
+ }
801
+ };
802
+
743
803
  // src/analytics/reportClientEvent.ts
744
804
  var makeSessionId = () => `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
745
805
  var toWireEvents = (events) => events.map((event) => {
@@ -781,17 +841,51 @@ var CURRENT_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
781
841
  "@wireai/activation:currentSessionId"
782
842
  );
783
843
  var globalSlot = globalThis;
844
+ var SESSION_ID_LISTENERS_SLOT = /* @__PURE__ */ Symbol.for(
845
+ "@wireai/activation:currentSessionIdListeners"
846
+ );
847
+ var listenerSlot = globalThis;
848
+ var listeners = () => {
849
+ const existing = listenerSlot[SESSION_ID_LISTENERS_SLOT];
850
+ if (existing) return existing;
851
+ const created = /* @__PURE__ */ new Set();
852
+ listenerSlot[SESSION_ID_LISTENERS_SLOT] = created;
853
+ return created;
854
+ };
855
+ var writeCurrentSessionId = (id) => {
856
+ if (globalSlot[CURRENT_SESSION_ID_SLOT] === id) return;
857
+ globalSlot[CURRENT_SESSION_ID_SLOT] = id;
858
+ for (const listener of Array.from(listeners())) {
859
+ try {
860
+ listener();
861
+ } catch {
862
+ }
863
+ }
864
+ };
865
+ var subscribeCurrentSessionId = (listener) => {
866
+ const set = listeners();
867
+ set.add(listener);
868
+ return () => {
869
+ set.delete(listener);
870
+ };
871
+ };
784
872
  var getCurrentSessionId = () => globalSlot[CURRENT_SESSION_ID_SLOT];
785
873
  var MINT_WARNING = "[wireai] No app-open session was registered, so a session id was minted for this event (the server drops an event that has no session_id, and still answers 200). Mount useLifecycleEvents at your app root so events correlate to a real app-open.";
786
874
  var MINT_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
787
875
  "@wireai/activation:currentSessionIdMintWarned"
788
876
  );
789
877
  var warnSlot = globalThis;
878
+ var MINTED_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
879
+ "@wireai/activation:mintedSessionId"
880
+ );
881
+ var mintedSlot = globalThis;
882
+ var isMintedSessionId = (id) => id !== void 0 && id === mintedSlot[MINTED_SESSION_ID_SLOT];
790
883
  var ensureCurrentSessionId = () => {
791
884
  const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
792
885
  if (typeof existing === "string" && existing.length > 0) return existing;
793
886
  const minted = makeSessionId();
794
- globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
887
+ writeCurrentSessionId(minted);
888
+ mintedSlot[MINTED_SESSION_ID_SLOT] = minted;
795
889
  if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
796
890
  warnSlot[MINT_WARNED_SLOT] = true;
797
891
  }
@@ -805,11 +899,15 @@ var submitReview = async (target, review) => {
805
899
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
806
900
  const headers = { "Content-Type": "application/json" };
807
901
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
808
- const res = await fetch(url, {
809
- method: "POST",
810
- headers,
811
- body: JSON.stringify(review)
812
- });
902
+ const res = await withDeadline(
903
+ (signal) => fetch(url, {
904
+ method: "POST",
905
+ headers,
906
+ body: JSON.stringify(review),
907
+ signal
908
+ })
909
+ );
910
+ if (res === DEADLINE_EXPIRED) return "unsent";
813
911
  if (!res) return "unsent";
814
912
  return res.ok ? "accepted" : "rejected";
815
913
  } catch {
@@ -827,9 +925,12 @@ var fetchReviewDecision = async (target, options = {}) => {
827
925
  const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
828
926
  const headers = {};
829
927
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
830
- const res = await fetch(url, { headers });
831
- if (!res || !res.ok) return null;
832
- const json = await res.json();
928
+ const json = await withDeadline(async (signal) => {
929
+ const res = await fetch(url, { headers, signal });
930
+ if (!res || !res.ok) return null;
931
+ return await res.json();
932
+ });
933
+ if (json === DEADLINE_EXPIRED) return null;
833
934
  if (!json || typeof json.fire !== "boolean") return null;
834
935
  return json;
835
936
  } catch {
@@ -901,8 +1002,33 @@ var _ReviewGate = ({
901
1002
  const resolve = React7.useCallback(() => {
902
1003
  onResolved == null ? void 0 : onResolved();
903
1004
  }, [onResolved]);
1005
+ const impressionRef = React7__default.default.useRef(null);
1006
+ const buildBody = React7.useCallback(
1007
+ (input) => {
1008
+ let impression = impressionRef.current;
1009
+ if (!impression) {
1010
+ impression = createImpression();
1011
+ impressionRef.current = impression;
1012
+ }
1013
+ const merged = { ...meta, ...input.extraMeta };
1014
+ if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
1015
+ return buildReviewSubmission({
1016
+ stars: input.stars,
1017
+ feedbackText: input.feedbackText,
1018
+ suggestion: input.suggestion,
1019
+ sessionId,
1020
+ meta: merged,
1021
+ idempotencyKey: impression.idempotencyKey
1022
+ });
1023
+ },
1024
+ [meta, sessionId]
1025
+ );
904
1026
  const postedRef = React7__default.default.useRef(false);
905
1027
  const unackedRef = React7__default.default.useRef(null);
1028
+ const recoverOnSettleRef = React7__default.default.useRef(false);
1029
+ const recoveredRef = React7__default.default.useRef(false);
1030
+ const postOnceRef = React7__default.default.useRef(() => {
1031
+ });
906
1032
  const postOnce = React7.useCallback(
907
1033
  (body) => {
908
1034
  if (postedRef.current) return;
@@ -911,6 +1037,10 @@ var _ReviewGate = ({
911
1037
  void submitReview(target, body).then((result) => {
912
1038
  if (result === "unsent") {
913
1039
  postedRef.current = false;
1040
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
1041
+ recoveredRef.current = true;
1042
+ postOnceRef.current(body);
1043
+ }
914
1044
  return;
915
1045
  }
916
1046
  unackedRef.current = null;
@@ -918,6 +1048,7 @@ var _ReviewGate = ({
918
1048
  },
919
1049
  [target]
920
1050
  );
1051
+ postOnceRef.current = postOnce;
921
1052
  const abandonRef = React7__default.default.useRef(() => {
922
1053
  });
923
1054
  abandonRef.current = () => {
@@ -926,14 +1057,17 @@ var _ReviewGate = ({
926
1057
  postOnce(undelivered);
927
1058
  return;
928
1059
  }
1060
+ if (postedRef.current && undelivered) {
1061
+ recoverOnSettleRef.current = true;
1062
+ return;
1063
+ }
929
1064
  if (postedRef.current || stars < 1) return;
930
1065
  postOnce(
931
- buildReviewSubmission({
1066
+ buildBody({
932
1067
  stars,
933
1068
  feedbackText: feedback,
934
1069
  suggestion,
935
- sessionId,
936
- meta: { ...meta, abandoned: true }
1070
+ extraMeta: { abandoned: true }
937
1071
  })
938
1072
  );
939
1073
  };
@@ -958,10 +1092,9 @@ var _ReviewGate = ({
958
1092
  onEvent == null ? void 0 : onEvent({ name: "store_review_requested", id, stars: value });
959
1093
  const route = await requestStoreReview(store);
960
1094
  postOnce(
961
- buildReviewSubmission({
1095
+ buildBody({
962
1096
  stars: value,
963
- sessionId,
964
- meta: { ...meta, store_route: route }
1097
+ extraMeta: { store_route: route }
965
1098
  })
966
1099
  );
967
1100
  setPhase("thanks");
@@ -970,24 +1103,18 @@ var _ReviewGate = ({
970
1103
  setPhase("feedback");
971
1104
  }
972
1105
  },
973
- [onEvent, id, store, postOnce, sessionId, meta, resolveWithDelay]
1106
+ [onEvent, id, store, postOnce, buildBody, resolveWithDelay]
974
1107
  );
975
1108
  const feedbackReady = feedback.trim().length > 0;
976
1109
  const sendFeedback = React7.useCallback(() => {
977
1110
  if (!feedbackReady) return;
978
- const body = buildReviewSubmission({
979
- stars,
980
- feedbackText: feedback,
981
- suggestion,
982
- sessionId,
983
- meta
984
- });
1111
+ const body = buildBody({ stars, feedbackText: feedback, suggestion });
985
1112
  postOnce(body);
986
1113
  onEvent == null ? void 0 : onEvent({ name: "review_feedback_submitted", id, stars });
987
1114
  playHaptic("success");
988
1115
  setPhase("thanks");
989
1116
  resolveWithDelay();
990
- }, [feedbackReady, stars, feedback, suggestion, sessionId, meta, postOnce, onEvent, id, resolveWithDelay]);
1117
+ }, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
991
1118
  const dismiss = React7.useCallback(() => {
992
1119
  setPhase("thanks");
993
1120
  resolveWithDelay();
@@ -1134,9 +1261,54 @@ var coachmarkRuntime = () => {
1134
1261
  var getCoachmarkStorage = () => coachmarkRuntime().storage;
1135
1262
  var isCoachmarkTesting = () => coachmarkRuntime().testing;
1136
1263
  var SEEN_VALUE = "1";
1264
+ var GATE_PROBE_KEY = "wire_gate_probe";
1265
+ var ASYNC_GATE_STORAGE_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
1266
+ "@wireai/activation:asyncGateStorageWarned"
1267
+ );
1268
+ var asyncGateWarnGlobal = globalThis;
1269
+ var closedGateStorage = Object.freeze({
1270
+ getItem: () => SEEN_VALUE,
1271
+ setItem: () => {
1272
+ }
1273
+ });
1274
+ var syncGateAdapters = /* @__PURE__ */ new WeakSet();
1275
+ var asyncGateAdapters = /* @__PURE__ */ new WeakSet();
1276
+ var warnAsyncGateStorage = () => {
1277
+ if (asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT]) return;
1278
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
1279
+ if (typeof console === "undefined" || !console.warn) return;
1280
+ asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = true;
1281
+ console.warn(
1282
+ "[wireai] the gate storage's getItem returned a Promise \u2014 an async adapter (e.g. AsyncStorage) cannot drive the once-per-user gates, so coachmark tours, showcases and the review/questionnaire gates are all DISABLED (fail closed: nothing shows, nothing fires) until a synchronous adapter is provided. Pass a sync adapter (e.g. MMKV: `{ getItem: (k) => mmkv.getString(k) ?? null, setItem: (k, v) => mmkv.set(k, v) }`) to CoachmarkProvider or the gate's `storage` prop."
1283
+ );
1284
+ };
1285
+ var validateGateStorage = (storage) => {
1286
+ if (!storage) return null;
1287
+ if (syncGateAdapters.has(storage)) return storage;
1288
+ if (asyncGateAdapters.has(storage)) return closedGateStorage;
1289
+ let probed;
1290
+ try {
1291
+ probed = storage.getItem(GATE_PROBE_KEY);
1292
+ } catch {
1293
+ syncGateAdapters.add(storage);
1294
+ return storage;
1295
+ }
1296
+ if (probed != null && typeof probed.then === "function") {
1297
+ asyncGateAdapters.add(storage);
1298
+ try {
1299
+ probed.then(void 0, () => {
1300
+ });
1301
+ } catch {
1302
+ }
1303
+ warnAsyncGateStorage();
1304
+ return closedGateStorage;
1305
+ }
1306
+ syncGateAdapters.add(storage);
1307
+ return storage;
1308
+ };
1137
1309
  var hasSeenGate = (key, storageOverride, testingOverride) => {
1138
1310
  if (testingOverride != null ? testingOverride : isCoachmarkTesting()) return false;
1139
- const storage = storageOverride != null ? storageOverride : getCoachmarkStorage();
1311
+ const storage = validateGateStorage(storageOverride != null ? storageOverride : getCoachmarkStorage());
1140
1312
  if (!storage) return false;
1141
1313
  try {
1142
1314
  return storage.getItem(key) === SEEN_VALUE;
@@ -1146,7 +1318,7 @@ var hasSeenGate = (key, storageOverride, testingOverride) => {
1146
1318
  };
1147
1319
  var markSeenGate = (key, storageOverride, testingOverride) => {
1148
1320
  if (testingOverride != null ? testingOverride : isCoachmarkTesting()) return;
1149
- const storage = storageOverride != null ? storageOverride : getCoachmarkStorage();
1321
+ const storage = validateGateStorage(storageOverride != null ? storageOverride : getCoachmarkStorage());
1150
1322
  if (!storage) return;
1151
1323
  try {
1152
1324
  storage.setItem(key, SEEN_VALUE);
@@ -1234,17 +1406,31 @@ var failOpen = async (storage, key) => {
1234
1406
  const cached = await readCachedFeatures(storage, key);
1235
1407
  return (_a = cached == null ? void 0 : cached.features) != null ? _a : defaultWireFeatures;
1236
1408
  };
1237
- var fetchWithTimeout = async (url, apiKey, timeoutMs) => {
1409
+ var NO_ANSWER = /* @__PURE__ */ Symbol("wire-features-no-answer");
1410
+ var fetchFeaturesJson = async (url, apiKey, timeoutMs) => {
1238
1411
  const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
1239
- const timer = setTimeout(() => controller == null ? void 0 : controller.abort(), timeoutMs);
1240
- try {
1241
- return await fetch(url, {
1412
+ let timer;
1413
+ const expired = new Promise((resolve) => {
1414
+ timer = setTimeout(() => {
1415
+ controller == null ? void 0 : controller.abort();
1416
+ resolve(NO_ANSWER);
1417
+ }, timeoutMs);
1418
+ });
1419
+ const exchange = (async () => {
1420
+ const res = await fetch(url, {
1242
1421
  method: "GET",
1243
1422
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
1244
1423
  signal: controller == null ? void 0 : controller.signal
1245
1424
  });
1425
+ if (!res || !res.ok) return NO_ANSWER;
1426
+ return await res.json();
1427
+ })();
1428
+ try {
1429
+ return await Promise.race([exchange, expired]);
1246
1430
  } finally {
1247
1431
  clearTimeout(timer);
1432
+ void exchange.catch(() => {
1433
+ });
1248
1434
  }
1249
1435
  };
1250
1436
  var fetchWireFeatures = async (config) => {
@@ -1254,9 +1440,8 @@ var fetchWireFeatures = async (config) => {
1254
1440
  const url = `${config.serverUrl.replace(/\/$/, "")}/v1/features`;
1255
1441
  const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
1256
1442
  try {
1257
- const res = await fetchWithTimeout(url, config.apiKey, timeoutMs);
1258
- if (!res || !res.ok) return failOpen(storage, key);
1259
- const json = await res.json();
1443
+ const json = await fetchFeaturesJson(url, config.apiKey, timeoutMs);
1444
+ if (json === NO_ANSWER) return failOpen(storage, key);
1260
1445
  const features = parseWireFeatures(json);
1261
1446
  if (storage) writeCachedFeatures(storage, key, features);
1262
1447
  return features;
@@ -1272,39 +1457,51 @@ var useStableValue = (value, isEqual) => {
1272
1457
  return ref.current;
1273
1458
  };
1274
1459
  var sameFetchKey = (a, b) => (a == null ? void 0 : a.serverUrl) === (b == null ? void 0 : b.serverUrl) && (a == null ? void 0 : a.apiKey) === (b == null ? void 0 : b.apiKey) && (a == null ? void 0 : a.appId) === (b == null ? void 0 : b.appId);
1275
- var useWireFeatures = (config) => {
1460
+ var useWireFeaturesState = (config) => {
1276
1461
  const stableConfig = useStableValue(config, sameFetchKey);
1277
1462
  const [flags, setFlags] = React7.useState(defaultWireFeatures);
1278
1463
  const canFetch = !!(stableConfig == null ? void 0 : stableConfig.serverUrl) && !!(stableConfig == null ? void 0 : stableConfig.apiKey);
1464
+ const [settled, setSettled] = React7.useState(!canFetch);
1279
1465
  React7.useEffect(() => {
1280
1466
  if (!canFetch) {
1281
1467
  setFlags((prev) => featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures);
1468
+ setSettled(true);
1282
1469
  return;
1283
1470
  }
1284
1471
  let alive = true;
1285
1472
  void fetchWireFeatures(stableConfig).then((next) => {
1286
1473
  if (!alive) return;
1287
1474
  setFlags((prev) => featuresEqual(prev, next) ? prev : next);
1475
+ setSettled(true);
1288
1476
  });
1289
1477
  return () => {
1290
1478
  alive = false;
1291
1479
  };
1292
1480
  }, [canFetch, stableConfig]);
1293
- return flags;
1481
+ return React7.useMemo(() => ({ flags, settled }), [flags, settled]);
1294
1482
  };
1295
1483
  var CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for("wireai.features.context");
1296
- var globalObj = globalThis;
1297
- if (!globalObj[CONTEXT_SYMBOL]) {
1298
- globalObj[CONTEXT_SYMBOL] = React7.createContext(null);
1484
+ var featuresContextGlobal = globalThis;
1485
+ if (!featuresContextGlobal[CONTEXT_SYMBOL]) {
1486
+ featuresContextGlobal[CONTEXT_SYMBOL] = React7.createContext(null);
1299
1487
  }
1300
- var WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
1488
+ var WireFeaturesContext = featuresContextGlobal[CONTEXT_SYMBOL];
1489
+ var SETTLED_SYMBOL = /* @__PURE__ */ Symbol.for("wireai.features.settled.context");
1490
+ var settledContextGlobal = globalThis;
1491
+ if (!settledContextGlobal[SETTLED_SYMBOL]) {
1492
+ settledContextGlobal[SETTLED_SYMBOL] = React7.createContext(true);
1493
+ }
1494
+ var WireFeaturesSettledContext = settledContextGlobal[SETTLED_SYMBOL];
1301
1495
  var useWireFeaturesContext = () => React7.useContext(WireFeaturesContext);
1302
- var useResolvedFeatures = (options) => {
1496
+ var useResolvedFeaturesState = (options) => {
1303
1497
  var _a, _b, _c;
1304
1498
  const ctx = useWireFeaturesContext();
1499
+ const ctxSettled = React7.useContext(WireFeaturesSettledContext);
1305
1500
  const shouldFetch = !(options == null ? void 0 : options.flags) && ctx == null;
1306
- const fetched = useWireFeatures(shouldFetch ? options == null ? void 0 : options.config : void 0);
1307
- return (_c = (_b = (_a = options == null ? void 0 : options.flags) != null ? _a : ctx) != null ? _b : fetched) != null ? _c : defaultWireFeatures;
1501
+ const fetched = useWireFeaturesState(shouldFetch ? options == null ? void 0 : options.config : void 0);
1502
+ const flags = (_c = (_b = (_a = options == null ? void 0 : options.flags) != null ? _a : ctx) != null ? _b : fetched.flags) != null ? _c : defaultWireFeatures;
1503
+ const settled = (options == null ? void 0 : options.flags) ? true : ctx != null ? ctxSettled : fetched.settled;
1504
+ return React7.useMemo(() => ({ flags, settled }), [flags, settled]);
1308
1505
  };
1309
1506
 
1310
1507
  // src/reviews/equality.ts
@@ -1333,7 +1530,7 @@ var reviewSeenKey = (id, version) => version ? `wire_review_${id}_${version}_see
1333
1530
  var reviewLastShownKey = (id) => `wire_review_${id}_last`;
1334
1531
  var reviewSessionsKey = (id) => `wire_review_${id}_sessions`;
1335
1532
  var reviewSessionOpenKey = (id) => `wire_review_${id}_open`;
1336
- var resolveStorage = (override) => override != null ? override : getCoachmarkStorage();
1533
+ var resolveStorage = (override) => validateGateStorage(override != null ? override : getCoachmarkStorage());
1337
1534
  var NO_STORAGE_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
1338
1535
  "@wireai/activation:gateStorageWarned"
1339
1536
  );
@@ -1378,18 +1575,23 @@ var PROCESS_OPEN_ID_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:proces
1378
1575
  var openIdGlobal = globalThis;
1379
1576
  var currentOpenId = () => {
1380
1577
  const live = getCurrentSessionId();
1578
+ const liveMinted = isMintedSessionId(live);
1381
1579
  const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
1382
1580
  if (!pin) {
1383
1581
  const openId = live != null ? live : makeSessionId();
1384
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
1582
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
1385
1583
  return openId;
1386
1584
  }
1387
1585
  if (!live || live === pin.observed) return pin.openId;
1388
- if (pin.observed === void 0) {
1389
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
1586
+ if (pin.observed === void 0 || pin.observedMinted) {
1587
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
1588
+ openId: pin.openId,
1589
+ observed: live,
1590
+ observedMinted: liveMinted
1591
+ };
1390
1592
  return pin.openId;
1391
1593
  }
1392
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
1594
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
1393
1595
  return live;
1394
1596
  };
1395
1597
  var bumpSessionCount = (storage, sessionsKey, openKey, openId = currentOpenId()) => {
@@ -1427,17 +1629,32 @@ var useReviewGate = ({
1427
1629
  decisionProp,
1428
1630
  (a, b) => sameDecision(a, b)
1429
1631
  );
1430
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig }).review.enabled;
1632
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
1633
+ flags: features,
1634
+ config: featuresConfig
1635
+ });
1636
+ const featureEnabled = resolvedFeatures.review.enabled;
1431
1637
  const testing = isTesting != null ? isTesting : isCoachmarkTesting();
1432
1638
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? void 0 : config.appVersion);
1433
1639
  const lastKey = reviewLastShownKey(config.id);
1434
1640
  const sessionsKey = reviewSessionsKey(config.id);
1435
1641
  const sessionOpenKey = reviewSessionOpenKey(config.id);
1436
- const sessions = React7.useState(() => {
1642
+ const openSessionId = React7.useSyncExternalStore(
1643
+ subscribeCurrentSessionId,
1644
+ getCurrentSessionId,
1645
+ getCurrentSessionId
1646
+ );
1647
+ const storageRef = React7.useRef(storage);
1648
+ storageRef.current = storage;
1649
+ const [sessions, setSessions] = React7.useState(() => {
1437
1650
  const store = resolveStorage(storage);
1438
1651
  warnMissingGateStorage(store, "review");
1439
1652
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
1440
- })[0];
1653
+ });
1654
+ React7.useEffect(() => {
1655
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
1656
+ setSessions((prev) => prev === next ? prev : next);
1657
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
1441
1658
  const hasServerDecision = decision != null;
1442
1659
  const timeoutMs = config.timeoutFallbackMs;
1443
1660
  const waits = !hasServerDecision && timeoutMs != null && timeoutMs > 0;
@@ -1463,9 +1680,9 @@ var useReviewGate = ({
1463
1680
  });
1464
1681
  const resolved = decideReview(local, decision);
1465
1682
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
1466
- const ready = hasServerDecision || elapsed;
1683
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
1467
1684
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
1468
- }, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
1685
+ }, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
1469
1686
  const shownRef = React7.useRef(false);
1470
1687
  const markShown = React7.useCallback(() => {
1471
1688
  if (shownRef.current) return;